Compare commits
20 Commits
671351aa89
..
main
| Author | SHA1 | Date | |
|---|---|---|---|
| d45a72c6a6 | |||
| 7dadcb8bcc | |||
| 55f646053d | |||
| 62cccfbcc6 | |||
| cb6498cd2b | |||
| 1429442dbd | |||
| 083d12016a | |||
| f10ef75852 | |||
| 87060e30d4 | |||
| 7083c45feb | |||
| 52f1930f9a | |||
| 3d0bcc8f6f | |||
| fede57e69a | |||
| 916b44bc3c | |||
| 62292edc70 | |||
| cbc88def27 | |||
| 48121b2a05 | |||
| a4f4be4c8e | |||
| 696bf2ef6e | |||
| 4126e01bba |
@@ -0,0 +1,21 @@
|
|||||||
|
# 环境变量配置示例
|
||||||
|
# 复制此文件为 .env 并修改需要的值
|
||||||
|
|
||||||
|
# ========== 开发模式 ==========
|
||||||
|
# 设置为 1 启用 Mock 硬件模式(本地开发,无需真实硬件)
|
||||||
|
MOCK_HARDWARE=0
|
||||||
|
|
||||||
|
# ========== 后端配置 ==========
|
||||||
|
FLASK_PORT=5000
|
||||||
|
|
||||||
|
# ========== 前端配置 ==========
|
||||||
|
# 前端开发服务器会代理 API 请求到后端
|
||||||
|
BACKEND_URL=http://127.0.0.1:5000
|
||||||
|
|
||||||
|
# ========== 硬件配置(生产环境) ==========
|
||||||
|
AGV_HOST=192.168.60.80
|
||||||
|
ARM_HOST=192.168.60.120
|
||||||
|
|
||||||
|
# ========== 外部 API ==========
|
||||||
|
# TEST_MODE: true=测试环境, false=正式环境
|
||||||
|
TEST_MODE=false
|
||||||
+208
@@ -0,0 +1,208 @@
|
|||||||
|
# ==============================
|
||||||
|
# Python
|
||||||
|
# ==============================
|
||||||
|
__pycache__/
|
||||||
|
*.py[cod]
|
||||||
|
*$py.class
|
||||||
|
*.so
|
||||||
|
.Python
|
||||||
|
build/
|
||||||
|
develop-eggs/
|
||||||
|
dist/
|
||||||
|
downloads/
|
||||||
|
eggs/
|
||||||
|
.eggs/
|
||||||
|
lib/
|
||||||
|
lib64/
|
||||||
|
parts/
|
||||||
|
sdist/
|
||||||
|
var/
|
||||||
|
wheels/
|
||||||
|
pip-wheel-metadata/
|
||||||
|
share/python-wheels/
|
||||||
|
*.egg-info/
|
||||||
|
.installed.cfg
|
||||||
|
*.egg
|
||||||
|
MANIFEST
|
||||||
|
|
||||||
|
# Virtual environments
|
||||||
|
.venv/
|
||||||
|
venv/
|
||||||
|
ENV/
|
||||||
|
env/
|
||||||
|
.virtualenv/
|
||||||
|
.virtenv/
|
||||||
|
|
||||||
|
# uv package manager
|
||||||
|
uv-cache/
|
||||||
|
|
||||||
|
# Python testing
|
||||||
|
.pytest_cache/
|
||||||
|
.coverage
|
||||||
|
.coverage.*
|
||||||
|
htmlcov/
|
||||||
|
*.cover
|
||||||
|
.hypothesis/
|
||||||
|
.pytest_cache/
|
||||||
|
|
||||||
|
# ==============================
|
||||||
|
# Node.js / npm
|
||||||
|
# ==============================
|
||||||
|
node_modules/
|
||||||
|
npm-debug.log*
|
||||||
|
yarn-debug.log*
|
||||||
|
yarn-error.log*
|
||||||
|
pnpm-debug.log*
|
||||||
|
lerna-debug.log*
|
||||||
|
|
||||||
|
# ==============================
|
||||||
|
# Next.js (Frontend)
|
||||||
|
# ==============================
|
||||||
|
.next/
|
||||||
|
out/
|
||||||
|
build/
|
||||||
|
dist/
|
||||||
|
.vercel/
|
||||||
|
*.tsbuildinfo
|
||||||
|
next-env.d.ts
|
||||||
|
|
||||||
|
# ==============================
|
||||||
|
# IDEs & Editors
|
||||||
|
# ==============================
|
||||||
|
# VSCode
|
||||||
|
.vscode/*
|
||||||
|
!.vscode/settings.json
|
||||||
|
!.vscode/tasks.json
|
||||||
|
!.vscode/launch.json
|
||||||
|
!.vscode/extensions.json
|
||||||
|
|
||||||
|
# JetBrains IDEs (IntelliJ, PyCharm, WebStorm, etc.)
|
||||||
|
.idea/
|
||||||
|
*.iml
|
||||||
|
*.ipr
|
||||||
|
*.iws
|
||||||
|
.idea_modules/
|
||||||
|
|
||||||
|
# Vim/Neovim
|
||||||
|
*.swp
|
||||||
|
*.swo
|
||||||
|
*~
|
||||||
|
.netrwhist
|
||||||
|
|
||||||
|
# Emacs
|
||||||
|
*~
|
||||||
|
\#*\#
|
||||||
|
.\#*
|
||||||
|
*.elc
|
||||||
|
|
||||||
|
# Sublime Text
|
||||||
|
*.sublime-project
|
||||||
|
*.sublime-workspace
|
||||||
|
|
||||||
|
# ==============================
|
||||||
|
# macOS
|
||||||
|
# ==============================
|
||||||
|
.DS_Store
|
||||||
|
._*
|
||||||
|
.AppleDouble
|
||||||
|
.LSOverride
|
||||||
|
.AppleDB
|
||||||
|
.AppleDesktop
|
||||||
|
Network Trash Folder
|
||||||
|
Temporary Items
|
||||||
|
.apdisk
|
||||||
|
|
||||||
|
# ==============================
|
||||||
|
# Windows
|
||||||
|
# ==============================
|
||||||
|
Thumbs.db
|
||||||
|
Thumbs.db:encryptable
|
||||||
|
ehthumbs.db
|
||||||
|
ehthumbs_vista.db
|
||||||
|
*.stackdump
|
||||||
|
[Dd]esktop.ini
|
||||||
|
$RECYCLE.BIN/
|
||||||
|
*.lnk
|
||||||
|
*.cab
|
||||||
|
*.msi
|
||||||
|
*.msix
|
||||||
|
*.msm
|
||||||
|
*.msp
|
||||||
|
*.lnk
|
||||||
|
|
||||||
|
# ==============================
|
||||||
|
# Linux
|
||||||
|
# ==============================
|
||||||
|
*~
|
||||||
|
.fuse_hidden*
|
||||||
|
.directory
|
||||||
|
.Trash-*
|
||||||
|
.nfs*
|
||||||
|
|
||||||
|
# ==============================
|
||||||
|
# Local runtime & temporary files
|
||||||
|
# ==============================
|
||||||
|
*.log
|
||||||
|
*.log.*
|
||||||
|
*.pid
|
||||||
|
*.seed
|
||||||
|
*.pid.lock
|
||||||
|
timeout
|
||||||
|
*.tmp
|
||||||
|
*.temp
|
||||||
|
*.cache
|
||||||
|
*.bak
|
||||||
|
*.bak.*
|
||||||
|
*.bak2
|
||||||
|
*.swp
|
||||||
|
*.swo
|
||||||
|
|
||||||
|
# ==============================
|
||||||
|
# Application specific
|
||||||
|
# ==============================
|
||||||
|
# Database
|
||||||
|
*.db
|
||||||
|
*.sqlite
|
||||||
|
*.sqlite3
|
||||||
|
*.db-shm
|
||||||
|
*.db-wal
|
||||||
|
|
||||||
|
# Environment files
|
||||||
|
.env
|
||||||
|
.env.*
|
||||||
|
!.env.example
|
||||||
|
!.env.local.example
|
||||||
|
*.local
|
||||||
|
|
||||||
|
# ==============================
|
||||||
|
# ROS2 / Robotics
|
||||||
|
# ==============================
|
||||||
|
install/
|
||||||
|
log/
|
||||||
|
build/
|
||||||
|
*/install/
|
||||||
|
*/log/
|
||||||
|
*/build/
|
||||||
|
*.bag
|
||||||
|
*.bag.active
|
||||||
|
|
||||||
|
# ==============================
|
||||||
|
# Misc
|
||||||
|
# ==============================
|
||||||
|
# Sensitive data (adjust patterns as needed)
|
||||||
|
*.pem
|
||||||
|
*.key
|
||||||
|
*.crt
|
||||||
|
*.secret
|
||||||
|
secrets/
|
||||||
|
|
||||||
|
# Large binary files (adjust as needed)
|
||||||
|
*.tar
|
||||||
|
*.tar.gz
|
||||||
|
*.zip
|
||||||
|
*.rar
|
||||||
|
*.7z
|
||||||
|
|
||||||
|
# Generated documentation
|
||||||
|
docs/_build/
|
||||||
|
site/
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
3.10
|
||||||
@@ -0,0 +1,31 @@
|
|||||||
|
# Repository Guidelines
|
||||||
|
|
||||||
|
## 项目结构与模块组织
|
||||||
|
|
||||||
|
本仓库是 AGV 智能巡检系统,后端、前端、ROS2 启动链路分目录维护。`agv_app/` 是 Flask 后端,包含 API、模板、静态资源与 `data/*.json` 持久化配置;`agv_app/utils/` 放置 AGV、Nav2、机械臂、二维码与任务执行逻辑。`arm_server/` 是机械臂端 TCP/摄像头服务及 systemd 配置。`scan_fixer/` 提供生产链路中的 ROS2 时间戳修正工具。`public-frontend/` 是 Next.js 平板端,源码在 `src/app`、`src/components`、`src/services`、`src/store`、`src/types`。`scripts/` 与 `Makefile` 负责本地开发、生产启动和停止流程,`docs/` 存放技术文档。
|
||||||
|
|
||||||
|
## 构建、测试与开发命令
|
||||||
|
|
||||||
|
- `make install`:执行 `uv sync` 并安装前端 npm 依赖。
|
||||||
|
- `make dev`:显示本地开发启动说明。
|
||||||
|
- `make dev-backend`:以 Mock 硬件模式启动 Flask 后端。
|
||||||
|
- `make dev-frontend`:启动 Next.js 开发服务器。
|
||||||
|
- `make prod`:在 AGV 上启动 ROS2、Nav2、scan fixer 与 Flask 完整生产链路。
|
||||||
|
- `cd public-frontend && npm run build`:构建前端。
|
||||||
|
- `cd public-frontend && npm run lint && npm run typecheck`:执行前端 lint 与 TypeScript 检查。
|
||||||
|
|
||||||
|
## 编码风格与命名约定
|
||||||
|
|
||||||
|
Python 目标版本为 3.10,依赖由根目录 `pyproject.toml` 和 `uv.lock` 管理。保持模块职责清晰,配置集中放在 `agv_app/config.py`,复用硬件抽象时优先放入 `agv_app/utils/`。前端使用 TypeScript、React、Next.js App Router 与 Ant Design;组件使用 PascalCase,例如 `CameraFrame.tsx`,服务和工具使用 camelCase,例如 `apiClient.ts`。新增代码应自解释命名,避免魔术值,必要注释只说明业务背景或非常规取舍。
|
||||||
|
|
||||||
|
## 测试指南
|
||||||
|
|
||||||
|
当前仓库未配置独立单元测试框架。提交前至少运行相关静态检查:前端改动运行 `npm run lint` 和 `npm run typecheck`,后端改动用 `make dev-backend` 做 Flask 启动与关键接口冒烟验证。涉及硬件、ROS2 或生产脚本的改动,应说明是否已在真实 AGV 或 Mock 模式验证。
|
||||||
|
|
||||||
|
## 提交与 Pull Request 规范
|
||||||
|
|
||||||
|
历史提交主要使用英文祈使句式,例如 `Improve camera status and production startup`、`Refactor ROS startup scripts`、`Fix shell compatibility issues in prod-backend.sh`。继续使用简短英文提交信息,避免无意义占位。PR 应包含变更摘要、验证命令、硬件/环境影响、相关 issue;前端界面变化需附截图或录屏,生产启动链路变化需列出影响的脚本和服务。
|
||||||
|
|
||||||
|
## 安全与配置提示
|
||||||
|
|
||||||
|
不要提交新的密钥、令牌、设备私有地址或本地日志。环境示例放在 `.env.example`,运行时覆盖优先使用环境变量,例如 `BACKEND_URL`、`MOCK_HARDWARE`、`AGV_PROJECT_DIR`。涉及 AGV、机械臂或 ROS2 生产配置时,先核对 `scripts/README.md` 中的默认路径与日志位置。
|
||||||
@@ -0,0 +1,57 @@
|
|||||||
|
.PHONY: help dev dev-backend dev-frontend stop prod install
|
||||||
|
|
||||||
|
help: ## 显示帮助信息
|
||||||
|
@echo "=========================================="
|
||||||
|
@echo " Smart Inspection DevOps"
|
||||||
|
@echo "=========================================="
|
||||||
|
@echo ""
|
||||||
|
@echo "本地开发命令:"
|
||||||
|
@echo " make dev 显示开发模式说明"
|
||||||
|
@echo " make dev-backend 启动后端 (Mock 硬件模式)"
|
||||||
|
@echo " make dev-frontend 启动前端 (Next.js)"
|
||||||
|
@echo " make stop 停止所有开发服务"
|
||||||
|
@echo ""
|
||||||
|
@echo "生产部署命令:"
|
||||||
|
@echo " make prod 启动生产环境 (完整系统)"
|
||||||
|
@echo ""
|
||||||
|
@echo "安装命令:"
|
||||||
|
@echo " make install 安装依赖"
|
||||||
|
@echo ""
|
||||||
|
|
||||||
|
dev: ## 显示开发模式说明
|
||||||
|
@echo "=========================================="
|
||||||
|
@echo " 本地开发模式"
|
||||||
|
@echo "=========================================="
|
||||||
|
@echo ""
|
||||||
|
@echo "需要两个终端:"
|
||||||
|
@echo " 终端 1: make dev-backend"
|
||||||
|
@echo " 终端 2: make dev-frontend"
|
||||||
|
@echo ""
|
||||||
|
@echo "或使用 tmux:"
|
||||||
|
@echo " tmux new-session 'make dev-backend' \\; split-window 'make dev-frontend'"
|
||||||
|
@echo ""
|
||||||
|
|
||||||
|
dev-backend: ## 启动后端开发服务器 (Mock 硬件模式)
|
||||||
|
@./scripts/dev-backend.sh
|
||||||
|
|
||||||
|
dev-frontend: ## 启动前端开发服务器
|
||||||
|
@./scripts/dev-frontend.sh
|
||||||
|
|
||||||
|
stop: ## 停止所有开发服务
|
||||||
|
@./scripts/stop.sh
|
||||||
|
|
||||||
|
prod: ## 启动生产环境 (完整系统)
|
||||||
|
@echo "=========================================="
|
||||||
|
@echo " 生产环境启动"
|
||||||
|
@echo "=========================================="
|
||||||
|
@echo ""
|
||||||
|
@echo "请在 AGV 上运行此命令"
|
||||||
|
@echo ""
|
||||||
|
@./scripts/prod-backend.sh
|
||||||
|
|
||||||
|
install: ## 安装依赖
|
||||||
|
@echo "安装 Python 依赖..."
|
||||||
|
@cd agv_app && uv sync
|
||||||
|
@echo "安装前端依赖..."
|
||||||
|
@cd public-frontend && npm install
|
||||||
|
@echo "完成"
|
||||||
+654
-83
@@ -8,16 +8,36 @@ import time
|
|||||||
import logging
|
import logging
|
||||||
import threading
|
import threading
|
||||||
import subprocess
|
import subprocess
|
||||||
|
import requests
|
||||||
from flask import Flask, render_template, jsonify, request, Response, send_from_directory
|
from flask import Flask, render_template, jsonify, request, Response, send_from_directory
|
||||||
from flask_cors import CORS
|
from flask_cors import CORS
|
||||||
|
|
||||||
from config import SERVER_CONFIG, ARM_CONFIG, AGV_CONFIG, UPLOAD_CONFIG, MAP_CONFIG, ARM_CAMERA_CONFIG, CAMERA_CONFIG, DATA_DIR, State
|
from config import (
|
||||||
from utils.arm_client import ArmClient
|
SERVER_CONFIG, ARM_CONFIG, AGV_CONFIG, UPLOAD_CONFIG, MAP_CONFIG,
|
||||||
from utils.agv_controller_ros2 import AGVController
|
ARM_CAMERA_CONFIG, CAMERA_CONFIG, DATA_DIR, State, ZHIJIAN_BASE_URL,
|
||||||
from utils.qr_scanner import QRScanner
|
ZHIJIAN_AUTH_TOKEN, set_api_mode, MOCK_HARDWARE
|
||||||
|
)
|
||||||
|
|
||||||
|
# 根据 MOCK_HARDWARE 配置选择导入真实或 Mock 实现
|
||||||
|
if MOCK_HARDWARE:
|
||||||
|
print("[启动] ===========================================")
|
||||||
|
print("[启动] Mock 硬件模式 - 本地开发环境")
|
||||||
|
print("[启动] ===========================================")
|
||||||
|
from utils.mock_hardware import (
|
||||||
|
MockArmClient as ArmClient,
|
||||||
|
MockAGVController as AGVController,
|
||||||
|
MockQRScanner as QRScanner,
|
||||||
|
MockNav2Navigator as Nav2Navigator,
|
||||||
|
MockNav2Status as Nav2Status
|
||||||
|
)
|
||||||
|
else:
|
||||||
|
from utils.arm_client import ArmClient
|
||||||
|
from utils.agv_controller_ros2 import AGVController
|
||||||
|
from utils.qr_scanner import QRScanner
|
||||||
|
from utils.nav2_navigator import Nav2Navigator, Nav2Status
|
||||||
|
|
||||||
from utils.image_uploader import ImageUploader
|
from utils.image_uploader import ImageUploader
|
||||||
from utils.mission_executor import MissionExecutorV3
|
from utils.mission_executor import MissionExecutorV3
|
||||||
from utils.nav2_navigator import Nav2Navigator, Nav2Status
|
|
||||||
|
|
||||||
# 配置日志
|
# 配置日志
|
||||||
logging.basicConfig(
|
logging.basicConfig(
|
||||||
@@ -26,6 +46,15 @@ logging.basicConfig(
|
|||||||
)
|
)
|
||||||
logger = logging.getLogger("agv_app")
|
logger = logging.getLogger("agv_app")
|
||||||
|
|
||||||
|
ORIGIN_X = 0.0
|
||||||
|
ORIGIN_Y = 0.0
|
||||||
|
ORIGIN_YAW = 0.0
|
||||||
|
ORIGIN_MATCH_TOLERANCE = 1e-6
|
||||||
|
ARM_RETURN_SPEED = 500
|
||||||
|
ARM_INITIAL_POSE_TIMEOUT = 15.0
|
||||||
|
ARM_INITIAL_POSE_TOLERANCE = 2.0
|
||||||
|
ARM_INITIAL_POSE_UNSET_TOLERANCE = 0.01
|
||||||
|
|
||||||
app = Flask(__name__, template_folder="templates", static_folder="static", static_url_path="/static")
|
app = Flask(__name__, template_folder="templates", static_folder="static", static_url_path="/static")
|
||||||
app.config["SECRET_KEY"] = SERVER_CONFIG["secret_key"]
|
app.config["SECRET_KEY"] = SERVER_CONFIG["secret_key"]
|
||||||
CORS(app)
|
CORS(app)
|
||||||
@@ -53,6 +82,8 @@ class GlobalState:
|
|||||||
self.machines_config = [] # 机器配置(每台机器的正面/背面点位+姿态)
|
self.machines_config = [] # 机器配置(每台机器的正面/背面点位+姿态)
|
||||||
self.qr_config = [] # 二维码配置(独立点位列表)
|
self.qr_config = [] # 二维码配置(独立点位列表)
|
||||||
self.navigator = None # Nav2Navigator 实例
|
self.navigator = None # Nav2Navigator 实例
|
||||||
|
self.current_customs = None # 当前设定的报关单信息
|
||||||
|
self.inspection = None # 查验状态 {customs_id, customs_name, items: [{inventoryCode, inventoryName, spec, quantify, inspected}]}
|
||||||
self.error_msg = "" # 错误弹窗消息(waiting_error 状态时)
|
self.error_msg = "" # 错误弹窗消息(waiting_error 状态时)
|
||||||
self.lock = threading.Lock()
|
self.lock = threading.Lock()
|
||||||
|
|
||||||
@@ -66,6 +97,60 @@ gs = GlobalState()
|
|||||||
|
|
||||||
|
|
||||||
# ========== 辅助函数 ==========
|
# ========== 辅助函数 ==========
|
||||||
|
def _is_origin_goal(x: float, y: float, yaw: float = None) -> bool:
|
||||||
|
yaw_is_origin = yaw is None or abs(yaw - ORIGIN_YAW) <= ORIGIN_MATCH_TOLERANCE
|
||||||
|
return (
|
||||||
|
abs(x - ORIGIN_X) <= ORIGIN_MATCH_TOLERANCE
|
||||||
|
and abs(y - ORIGIN_Y) <= ORIGIN_MATCH_TOLERANCE
|
||||||
|
and yaw_is_origin
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _normalize_arm_pose(raw_pose):
|
||||||
|
if not isinstance(raw_pose, list) or len(raw_pose) != 6:
|
||||||
|
return None
|
||||||
|
try:
|
||||||
|
return [float(angle) for angle in raw_pose]
|
||||||
|
except (TypeError, ValueError):
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
def _wait_arm_initial_pose(target_angles, timeout=ARM_INITIAL_POSE_TIMEOUT, tolerance=ARM_INITIAL_POSE_TOLERANCE):
|
||||||
|
deadline = time.time() + timeout
|
||||||
|
while time.time() < deadline:
|
||||||
|
try:
|
||||||
|
ok, current_angles = gs.arm_client.get_angles()
|
||||||
|
if ok and current_angles and len(current_angles) >= 6:
|
||||||
|
current_angles = [float(angle) for angle in current_angles[:6]]
|
||||||
|
if all(abs(current_angles[index] - target_angles[index]) <= tolerance for index in range(6)):
|
||||||
|
return True
|
||||||
|
except Exception as e:
|
||||||
|
logger.warning(f"等待机械臂初始姿态失败: {e}")
|
||||||
|
time.sleep(0.5)
|
||||||
|
return False
|
||||||
|
|
||||||
|
|
||||||
|
def _restore_arm_initial_pose_for_origin():
|
||||||
|
arm_initial_pose = _normalize_arm_pose(gs.mission_config.get("arm_initial_pose"))
|
||||||
|
if not arm_initial_pose:
|
||||||
|
return {"ok": True, "skipped": True, "message": "未配置有效机械臂初始姿态,跳过复原"}
|
||||||
|
if not any(abs(angle) > ARM_INITIAL_POSE_UNSET_TOLERANCE for angle in arm_initial_pose):
|
||||||
|
return {"ok": True, "skipped": True, "message": "未配置机械臂初始姿态,跳过复原"}
|
||||||
|
if not gs.arm_client:
|
||||||
|
return {"ok": False, "error": "机械臂未连接,无法先复原再回原点"}
|
||||||
|
|
||||||
|
try:
|
||||||
|
ok = gs.arm_client.set_angles(arm_initial_pose, ARM_RETURN_SPEED)
|
||||||
|
if not ok:
|
||||||
|
return {"ok": False, "error": "机械臂初始姿态指令发送失败,已取消回原点"}
|
||||||
|
if not _wait_arm_initial_pose(arm_initial_pose):
|
||||||
|
return {"ok": False, "error": "机械臂未在规定时间内复原,已取消回原点"}
|
||||||
|
return {"ok": True, "skipped": False, "message": "机械臂已复原到初始姿态"}
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"回原点前复原机械臂失败: {e}")
|
||||||
|
return {"ok": False, "error": f"回原点前复原机械臂失败: {e}"}
|
||||||
|
|
||||||
|
|
||||||
def get_data_path(name):
|
def get_data_path(name):
|
||||||
return os.path.join(DATA_DIR, name)
|
return os.path.join(DATA_DIR, name)
|
||||||
|
|
||||||
@@ -126,12 +211,21 @@ try:
|
|||||||
# Flask 2.3+ 方式
|
# Flask 2.3+ 方式
|
||||||
with app.app_context():
|
with app.app_context():
|
||||||
load_persisted_config()
|
load_persisted_config()
|
||||||
# 启动时自动重连 AGV(异步,不阻塞 Flask 启动)
|
# 启动时自动连接所有设备(异步,不阻塞 Flask 启动)
|
||||||
import threading
|
import threading
|
||||||
def _auto_reconnect():
|
def _auto_connect_all():
|
||||||
time.sleep(2) # 等待 Flask 完全就绪
|
time.sleep(2) # 等待 Flask 完全就绪
|
||||||
|
if MOCK_HARDWARE:
|
||||||
|
print("[启动] Mock 模式跳过硬件自动连接")
|
||||||
|
# Mock 模式下也创建实例供 API 使用
|
||||||
|
gs.agv_controller = AGVController()
|
||||||
|
gs.arm_client = ArmClient(ARM_CONFIG["host"], ARM_CONFIG["port"])
|
||||||
|
gs.qr_scanner = QRScanner(CAMERA_CONFIG["device_index"])
|
||||||
|
gs.navigator = Nav2Navigator()
|
||||||
|
return
|
||||||
|
|
||||||
|
# 连接 AGV(真实硬件)
|
||||||
try:
|
try:
|
||||||
from utils.agv_controller_ros2 import AGVController
|
|
||||||
gs.agv_controller = AGVController()
|
gs.agv_controller = AGVController()
|
||||||
if gs.agv_controller.connect():
|
if gs.agv_controller.connect():
|
||||||
print("[启动] AGV 自动连接成功")
|
print("[启动] AGV 自动连接成功")
|
||||||
@@ -139,7 +233,18 @@ try:
|
|||||||
print("[启动] AGV 自动连接失败,请手动连接")
|
print("[启动] AGV 自动连接失败,请手动连接")
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
print(f"[启动] AGV 自动连接异常: {e}")
|
print(f"[启动] AGV 自动连接异常: {e}")
|
||||||
threading.Thread(target=_auto_reconnect, daemon=True).start()
|
# 连接机械臂(真实硬件)
|
||||||
|
try:
|
||||||
|
gs.arm_client = ArmClient(ARM_CONFIG["host"], ARM_CONFIG["port"])
|
||||||
|
if gs.arm_client.connect():
|
||||||
|
gs.arm_client.power_on()
|
||||||
|
gs.arm_client.state_on()
|
||||||
|
print("[启动] 机械臂自动连接成功")
|
||||||
|
else:
|
||||||
|
print("[启动] 机械臂自动连接失败,请手动连接")
|
||||||
|
except Exception as e:
|
||||||
|
print(f"[启动] 机械臂自动连接异常: {e}")
|
||||||
|
threading.Thread(target=_auto_connect_all, daemon=True).start()
|
||||||
except:
|
except:
|
||||||
# 兼容旧版 Flask
|
# 兼容旧版 Flask
|
||||||
@app.before_first_request
|
@app.before_first_request
|
||||||
@@ -165,25 +270,22 @@ def running_page():
|
|||||||
def api_status():
|
def api_status():
|
||||||
"""获取系统整体状态"""
|
"""获取系统整体状态"""
|
||||||
with gs.lock:
|
with gs.lock:
|
||||||
# 实际验证机械臂连接(尝试发送一个简单命令)
|
arm_connected = gs.arm_client.is_connected() if gs.arm_client else False
|
||||||
arm_connected = False
|
|
||||||
if gs.arm_client and gs.arm_client._sock:
|
|
||||||
try:
|
|
||||||
# 设置短超时尝试获取角度,验证连接是否有效
|
|
||||||
gs.arm_client._sock.settimeout(2)
|
|
||||||
ok, _ = gs.arm_client.get_angles()
|
|
||||||
arm_connected = ok
|
|
||||||
except:
|
|
||||||
arm_connected = False
|
|
||||||
# 连接已断开,清理 socket
|
|
||||||
if gs.arm_client:
|
|
||||||
gs.arm_client._sock = None
|
|
||||||
|
|
||||||
# 实际验证 AGV 连接
|
# 实际验证 AGV 连接
|
||||||
agv_connected = False
|
agv_connected = False
|
||||||
if gs.agv_controller:
|
if gs.agv_controller:
|
||||||
agv_connected = gs.agv_controller.is_connected()
|
agv_connected = gs.agv_controller.is_connected()
|
||||||
|
|
||||||
|
# 实时检测机械臂摄像头是否可用
|
||||||
|
try:
|
||||||
|
import requests as _armcam_req
|
||||||
|
_r = _armcam_req.get(ARM_CAMERA_CONFIG["url"], stream=True, timeout=3)
|
||||||
|
gs.arm_camera_opened = (_r.status_code == 200)
|
||||||
|
_r.close()
|
||||||
|
except:
|
||||||
|
gs.arm_camera_opened = False
|
||||||
|
|
||||||
return jsonify({
|
return jsonify({
|
||||||
"state": gs.state,
|
"state": gs.state,
|
||||||
"agv_connected": agv_connected,
|
"agv_connected": agv_connected,
|
||||||
@@ -191,6 +293,8 @@ def api_status():
|
|||||||
"camera_opened": gs.camera_opened,
|
"camera_opened": gs.camera_opened,
|
||||||
"arm_camera_opened": gs.arm_camera_opened,
|
"arm_camera_opened": gs.arm_camera_opened,
|
||||||
"map_loaded": bool(gs.map_config),
|
"map_loaded": bool(gs.map_config),
|
||||||
|
"map": gs.map_config,
|
||||||
|
"has_agv_camera": gs.camera_opened,
|
||||||
"points_count": len(gs.points_config),
|
"points_count": len(gs.points_config),
|
||||||
"models_count": len(gs.models_config),
|
"models_count": len(gs.models_config),
|
||||||
"mission_rows": gs.mission_config.get("rows", 0),
|
"mission_rows": gs.mission_config.get("rows", 0),
|
||||||
@@ -423,24 +527,38 @@ def api_navigate_to():
|
|||||||
if not gs.map_config or "map_yaml" not in gs.map_config:
|
if not gs.map_config or "map_yaml" not in gs.map_config:
|
||||||
return jsonify({"ok": False, "error": "地图未加载,请先在设置中加载地图"}), 400
|
return jsonify({"ok": False, "error": "地图未加载,请先在设置中加载地图"}), 400
|
||||||
|
|
||||||
data = request.json
|
data = request.json or {}
|
||||||
goal_x = data.get("x")
|
goal_x = data.get("x")
|
||||||
goal_y = data.get("y")
|
goal_y = data.get("y")
|
||||||
goal_yaw = data.get("yaw") # 姿态参数,可选
|
goal_yaw = data.get("yaw") # 姿态参数,可选
|
||||||
if goal_x is None or goal_y is None:
|
if goal_x is None or goal_y is None:
|
||||||
return jsonify({"ok": False, "error": "缺少目标坐标 x, y"}), 400
|
return jsonify({"ok": False, "error": "缺少目标坐标 x, y"}), 400
|
||||||
|
try:
|
||||||
|
goal_x = float(goal_x)
|
||||||
|
goal_y = float(goal_y)
|
||||||
|
yaw_arg = float(goal_yaw) if goal_yaw is not None else None
|
||||||
|
except (TypeError, ValueError):
|
||||||
|
return jsonify({"ok": False, "error": "目标坐标格式错误"}), 400
|
||||||
|
|
||||||
if not gs.agv_controller or not gs.agv_controller.is_connected():
|
if not gs.agv_controller or not gs.agv_controller.is_connected():
|
||||||
return jsonify({"ok": False, "error": "AGV 未连接,请先连接 AGV"}), 400
|
return jsonify({"ok": False, "error": "AGV 未连接,请先连接 AGV"}), 400
|
||||||
|
|
||||||
try:
|
try:
|
||||||
|
restore_message = ""
|
||||||
|
restore_arm = bool(data.get("restore_arm", True))
|
||||||
|
if restore_arm and _is_origin_goal(goal_x, goal_y, yaw_arg):
|
||||||
|
restore_result = _restore_arm_initial_pose_for_origin()
|
||||||
|
if not restore_result["ok"]:
|
||||||
|
return jsonify({"ok": False, "error": restore_result["error"]}), 400
|
||||||
|
restore_message = restore_result.get("message", "")
|
||||||
|
|
||||||
if gs.navigator is None:
|
if gs.navigator is None:
|
||||||
gs.navigator = Nav2Navigator()
|
gs.navigator = Nav2Navigator()
|
||||||
# navigate_to_pose(x, y, yaw=None, timeout_sec=120, blocking=False)
|
# navigate_to_pose(x, y, yaw=None, timeout_sec=120, blocking=False)
|
||||||
yaw_arg = float(goal_yaw) if goal_yaw is not None else None
|
ok = gs.navigator.navigate_to_pose(goal_x, goal_y, yaw_arg, blocking=False)
|
||||||
ok = gs.navigator.navigate_to_pose(float(goal_x), float(goal_y), yaw_arg, blocking=False)
|
|
||||||
if ok:
|
if ok:
|
||||||
return jsonify({"ok": True, "message": "导航已启动"})
|
message = f"{restore_message},导航已启动" if restore_message else "导航已启动"
|
||||||
|
return jsonify({"ok": True, "message": message})
|
||||||
else:
|
else:
|
||||||
return jsonify({"ok": False, "error": "导航启动失败,可能是Nav2未运行或AGV未连接"}), 400
|
return jsonify({"ok": False, "error": "导航启动失败,可能是Nav2未运行或AGV未连接"}), 400
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
@@ -747,11 +865,7 @@ def api_mission_config_set():
|
|||||||
gs.mission_config["cols"] = cols
|
gs.mission_config["cols"] = cols
|
||||||
gs.mission_config["grid"] = grid
|
gs.mission_config["grid"] = grid
|
||||||
gs.mission_config["arm_initial_pose"] = arm_initial_pose
|
gs.mission_config["arm_initial_pose"] = arm_initial_pose
|
||||||
# 清除超出网格边界的 positions(只保留 front/back 且 row<=rows, col<cols)
|
# 点位数据始终保留,不随网格变更删除
|
||||||
gs.mission_config["positions"] = [
|
|
||||||
p for p in gs.mission_config.get("positions", [])
|
|
||||||
if p.get("row", 0) <= rows and p.get("col", 0) < cols and p.get("side") in ("front", "back")
|
|
||||||
]
|
|
||||||
save_json("mission_config.json", gs.mission_config)
|
save_json("mission_config.json", gs.mission_config)
|
||||||
return jsonify({"ok": True, "config": gs.mission_config})
|
return jsonify({"ok": True, "config": gs.mission_config})
|
||||||
|
|
||||||
@@ -1067,41 +1181,33 @@ def api_arm_state_off():
|
|||||||
@app.route("/api/camera/preview")
|
@app.route("/api/camera/preview")
|
||||||
def api_camera_preview():
|
def api_camera_preview():
|
||||||
"""MJPEG 视频流"""
|
"""MJPEG 视频流"""
|
||||||
if not gs.qr_scanner or not gs.qr_scanner._cap:
|
if not gs.qr_scanner or not gs.camera_opened:
|
||||||
return "camera not opened", 400
|
return "camera not opened", 400
|
||||||
|
|
||||||
|
import time as _time
|
||||||
def gen():
|
def gen():
|
||||||
|
_last_ok = _time.time()
|
||||||
while True:
|
while True:
|
||||||
frame = gs.qr_scanner.read_frame()
|
frame = gs.qr_scanner.read_frame()
|
||||||
if frame is None:
|
if frame is None:
|
||||||
break
|
if _time.time() - _last_ok > 5:
|
||||||
# 编码为 JPEG
|
break
|
||||||
|
_time.sleep(0.05)
|
||||||
|
continue
|
||||||
import cv2
|
import cv2
|
||||||
ret, buf = cv2.imencode(".jpg", frame)
|
ret, buf = cv2.imencode(".jpg", frame)
|
||||||
if ret:
|
if ret:
|
||||||
|
_last_ok = _time.time()
|
||||||
yield (b"--frame\r\nContent-Type: image/jpeg\r\n\r\n" +
|
yield (b"--frame\r\nContent-Type: image/jpeg\r\n\r\n" +
|
||||||
buf.tobytes() + b"\r\n")
|
buf.tobytes() + b"\r\n")
|
||||||
|
|
||||||
return Response(gen(), mimetype="multipart/x-mixed-replace; boundary=frame")
|
return Response(gen(), mimetype="multipart/x-mixed-replace; boundary=frame")
|
||||||
|
|
||||||
@app.route("/api/camera/refresh")
|
|
||||||
def api_camera_refresh():
|
|
||||||
"""AGV 摄像头单帧 JPEG(polling 模式)"""
|
|
||||||
if not gs.qr_scanner or not gs.qr_scanner._cap:
|
|
||||||
return "camera not opened", 400
|
|
||||||
import cv2
|
|
||||||
frame = gs.qr_scanner.read_frame()
|
|
||||||
if frame is None:
|
|
||||||
return "", 400
|
|
||||||
ret, buf = cv2.imencode(".jpg", frame)
|
|
||||||
if ret:
|
|
||||||
return Response(buf.tobytes(), mimetype="image/jpeg")
|
|
||||||
return "encode failed", 500
|
|
||||||
|
|
||||||
@app.route("/api/camera/capture")
|
@app.route("/api/camera/capture")
|
||||||
def api_camera_capture():
|
def api_camera_capture():
|
||||||
"""拍摄一张照片"""
|
"""拍摄一张照片"""
|
||||||
if not gs.qr_scanner or not gs.qr_scanner._cap:
|
if not gs.qr_scanner or not gs.camera_opened:
|
||||||
return jsonify({"ok": False, "error": "摄像头未打开"}), 400
|
return jsonify({"ok": False, "error": "摄像头未打开"}), 400
|
||||||
import cv2
|
import cv2
|
||||||
frame = gs.qr_scanner.read_frame()
|
frame = gs.qr_scanner.read_frame()
|
||||||
@@ -1114,27 +1220,98 @@ def api_camera_capture():
|
|||||||
cv2.imwrite(photo_path, frame)
|
cv2.imwrite(photo_path, frame)
|
||||||
return jsonify({"ok": True, "path": photo_path})
|
return jsonify({"ok": True, "path": photo_path})
|
||||||
|
|
||||||
|
|
||||||
|
def _is_corrupted_jpeg(jpeg_bytes: bytes) -> float:
|
||||||
|
"""检测 JPEG 是否为花屏帧。返回 0~1 的置信度 (1=确定花屏)。"""
|
||||||
|
try:
|
||||||
|
import cv2
|
||||||
|
import numpy as np
|
||||||
|
arr = np.frombuffer(jpeg_bytes, dtype=np.uint8)
|
||||||
|
img = cv2.imdecode(arr, cv2.IMREAD_COLOR)
|
||||||
|
if img is None:
|
||||||
|
return 1.0
|
||||||
|
h, w = img.shape[:2]
|
||||||
|
if h < 10 or w < 10:
|
||||||
|
return 1.0
|
||||||
|
|
||||||
|
hsv = cv2.cvtColor(img, cv2.COLOR_BGR2HSV)
|
||||||
|
|
||||||
|
# 绿色条纹检测:HSV 中绿色 H=40~80
|
||||||
|
green_mask = cv2.inRange(hsv, (40, 30, 40), (80, 255, 255))
|
||||||
|
green_ratio = cv2.countNonZero(green_mask) / (h * w)
|
||||||
|
|
||||||
|
# 紫色/品红条纹
|
||||||
|
purple_mask = cv2.inRange(hsv, (130, 30, 40), (170, 255, 255))
|
||||||
|
purple_ratio = cv2.countNonZero(purple_mask) / (h * w)
|
||||||
|
|
||||||
|
if green_ratio > 0.80 or purple_ratio > 0.80:
|
||||||
|
return 0.95
|
||||||
|
|
||||||
|
gray = cv2.cvtColor(img, cv2.COLOR_BGR2GRAY)
|
||||||
|
row_stds = np.std(gray, axis=1)
|
||||||
|
col_stds = np.std(gray, axis=0)
|
||||||
|
|
||||||
|
row_std_of_stds = float(np.std(row_stds))
|
||||||
|
col_std_of_stds = float(np.std(col_stds))
|
||||||
|
|
||||||
|
if row_std_of_stds > 70 and col_std_of_stds > 30:
|
||||||
|
return 0.85
|
||||||
|
|
||||||
|
unique_colors = len(np.unique(img.reshape(-1, 3), axis=0))
|
||||||
|
if unique_colors < 200:
|
||||||
|
return 0.75
|
||||||
|
|
||||||
|
return 0.0
|
||||||
|
except ImportError:
|
||||||
|
return 0.0
|
||||||
|
except Exception:
|
||||||
|
return 0.0
|
||||||
|
|
||||||
|
|
||||||
@app.route("/api/camera/arm_refresh")
|
@app.route("/api/camera/arm_refresh")
|
||||||
def api_arm_camera_refresh():
|
def api_arm_camera_refresh():
|
||||||
"""从机械臂拉一张 JPEG(请求 snapshot 端点,简单 HTTP GET)"""
|
"""从机械臂拉一张 JPEG,翻转后返回(机械臂摄像头物理倒装)。"""
|
||||||
import requests
|
import requests
|
||||||
try:
|
import cv2
|
||||||
r = requests.get(ARM_CAMERA_CONFIG.get("snapshot_url", ARM_CAMERA_CONFIG["url"]), timeout=8)
|
import numpy as np
|
||||||
if r.status_code == 200 and r.content:
|
url = ARM_CAMERA_CONFIG.get("snapshot_url", ARM_CAMERA_CONFIG["url"])
|
||||||
resp = Response(r.content, mimetype="image/jpeg")
|
max_retries = 3
|
||||||
resp.headers["Cache-Control"] = "no-cache, no-store, must-revalidate, max-age=0"
|
for attempt in range(1, max_retries + 1):
|
||||||
resp.headers["Pragma"] = "no-cache"
|
try:
|
||||||
resp.headers["Expires"] = "0"
|
r = requests.get(url, timeout=8)
|
||||||
return resp
|
if r.status_code == 200 and r.content:
|
||||||
return "", 404
|
corruption = _is_corrupted_jpeg(r.content)
|
||||||
except Exception as ex:
|
if corruption > 0.5:
|
||||||
logger.info(f"arm_refresh 不可用: {ex}")
|
logger.warning(f"arm_refresh 第{attempt}次尝试检测到花屏 (置信度{corruption:.2f}),重试...")
|
||||||
return "", 404
|
time.sleep(0.3)
|
||||||
|
continue
|
||||||
|
# 解码 → 上下翻转 → 编码
|
||||||
|
img = cv2.imdecode(np.frombuffer(r.content, dtype=np.uint8), cv2.IMREAD_COLOR)
|
||||||
|
if img is not None:
|
||||||
|
img = cv2.flip(img, 0) # 0 = 上下翻转
|
||||||
|
ret, jpg = cv2.imencode(".jpg", img, [cv2.IMWRITE_JPEG_QUALITY, 85])
|
||||||
|
if ret:
|
||||||
|
resp = Response(jpg.tobytes(), mimetype="image/jpeg")
|
||||||
|
resp.headers["Cache-Control"] = "no-cache, no-store, must-revalidate, max-age=0"
|
||||||
|
resp.headers["Pragma"] = "no-cache"
|
||||||
|
resp.headers["Expires"] = "0"
|
||||||
|
return resp
|
||||||
|
resp = Response(r.content, mimetype="image/jpeg")
|
||||||
|
resp.headers["Cache-Control"] = "no-cache, no-store, must-revalidate, max-age=0"
|
||||||
|
resp.headers["Pragma"] = "no-cache"
|
||||||
|
resp.headers["Expires"] = "0"
|
||||||
|
return resp
|
||||||
|
return "", 404
|
||||||
|
except Exception as ex:
|
||||||
|
logger.info(f"arm_refresh 尝试{attempt}/{max_retries} 失败: {ex}")
|
||||||
|
time.sleep(0.5)
|
||||||
|
logger.warning(f"arm_refresh 在 {max_retries} 次尝试后仍失败")
|
||||||
|
return "", 404
|
||||||
|
|
||||||
|
|
||||||
@app.route("/api/camera/arm_preview")
|
@app.route("/api/camera/arm_preview")
|
||||||
def api_arm_camera_preview():
|
def api_arm_camera_preview():
|
||||||
"""代理机械臂 MJPEG 视频流,供页面连续预览。"""
|
"""代理机械臂 MJPEG 视频流,直透返回(不翻转)。"""
|
||||||
import requests
|
import requests
|
||||||
try:
|
try:
|
||||||
upstream = requests.get(
|
upstream = requests.get(
|
||||||
@@ -1147,10 +1324,23 @@ def api_arm_camera_preview():
|
|||||||
return "", 404
|
return "", 404
|
||||||
|
|
||||||
def generate():
|
def generate():
|
||||||
|
buf = b""
|
||||||
try:
|
try:
|
||||||
for chunk in upstream.iter_content(chunk_size=8192):
|
for chunk in upstream.iter_content(chunk_size=8192):
|
||||||
if chunk:
|
if not chunk:
|
||||||
yield chunk
|
continue
|
||||||
|
buf += chunk
|
||||||
|
# 查找 MJPEG 帧边界
|
||||||
|
while True:
|
||||||
|
start = buf.find(b"\xff\xd8")
|
||||||
|
end = buf.find(b"\xff\xd9")
|
||||||
|
if start != -1 and end != -1 and end > start:
|
||||||
|
jpg_data = buf[start:end+2]
|
||||||
|
buf = buf[end+2:]
|
||||||
|
# 直透原始帧(不翻转)
|
||||||
|
yield b"--frame\r\nContent-Type: image/jpeg\r\n\r\n" + jpg_data + b"\r\n"
|
||||||
|
else:
|
||||||
|
break
|
||||||
finally:
|
finally:
|
||||||
upstream.close()
|
upstream.close()
|
||||||
|
|
||||||
@@ -1179,7 +1369,7 @@ def api_agv_move():
|
|||||||
"""控制 AGV 移动(前进/后退/左转/右转/停止)"""
|
"""控制 AGV 移动(前进/后退/左转/右转/停止)"""
|
||||||
data = request.json
|
data = request.json
|
||||||
direction = data.get("direction", "stop") # forward / backward / left / right / stop
|
direction = data.get("direction", "stop") # forward / backward / left / right / stop
|
||||||
speed = data.get("speed", AGV_CONFIG.get("move_speed", 0.5))
|
speed = data.get("speed", AGV_CONFIG.get("move_speed", 1.0))
|
||||||
if not gs.agv_controller or not gs.agv_controller.is_connected():
|
if not gs.agv_controller or not gs.agv_controller.is_connected():
|
||||||
return jsonify({"ok": False, "error": "AGV 未连接"}), 400
|
return jsonify({"ok": False, "error": "AGV 未连接"}), 400
|
||||||
try:
|
try:
|
||||||
@@ -1242,6 +1432,11 @@ def api_agv_reset():
|
|||||||
def api_mission_start():
|
def api_mission_start():
|
||||||
"""开始执行任务(V3: M×N Grid 蛇形路径)"""
|
"""开始执行任务(V3: M×N Grid 蛇形路径)"""
|
||||||
data = request.json or {}
|
data = request.json or {}
|
||||||
|
|
||||||
|
# 必须先设置报关单(开始查验)
|
||||||
|
if not gs.inspection:
|
||||||
|
return jsonify({"ok": False, "error": "请先在「设置→报关单」中选择报关单并点击「开始查验」"}), 400
|
||||||
|
|
||||||
single_step = bool(data.get("single_step", False))
|
single_step = bool(data.get("single_step", False))
|
||||||
# 任务步骤控制开关
|
# 任务步骤控制开关
|
||||||
options = {
|
options = {
|
||||||
@@ -1250,8 +1445,8 @@ def api_mission_start():
|
|||||||
"qr_scan": bool(data.get("qr_scan", True)),
|
"qr_scan": bool(data.get("qr_scan", True)),
|
||||||
"front_photo": bool(data.get("front_photo", True)),
|
"front_photo": bool(data.get("front_photo", True)),
|
||||||
"back_photo": bool(data.get("back_photo", True)),
|
"back_photo": bool(data.get("back_photo", True)),
|
||||||
"agv_speed": float(data.get("agv_speed", 0.5)),
|
"agv_speed": float(data.get("agv_speed", 1.0)),
|
||||||
"arm_speed": int(data.get("arm_speed", 500)),
|
"arm_speed": int(data.get("arm_speed", 1000)),
|
||||||
}
|
}
|
||||||
print(f"[Mission] options: {options}")
|
print(f"[Mission] options: {options}")
|
||||||
|
|
||||||
@@ -1436,6 +1631,18 @@ def api_mission_state():
|
|||||||
result["waiting_step"] = False
|
result["waiting_step"] = False
|
||||||
result["waiting_error"] = False
|
result["waiting_error"] = False
|
||||||
|
|
||||||
|
# 查验状态
|
||||||
|
result["inspection"] = gs.inspection
|
||||||
|
|
||||||
|
# QR 输入弹窗消息
|
||||||
|
if hasattr(MissionExecutorV3, "_instance") and MissionExecutorV3._instance:
|
||||||
|
rpt = MissionExecutorV3._instance.report
|
||||||
|
result["qr_message"] = rpt.get("qr_message", "")
|
||||||
|
result["step_label"] = rpt.get("step_label", "")
|
||||||
|
else:
|
||||||
|
result["qr_message"] = ""
|
||||||
|
result["step_label"] = ""
|
||||||
|
|
||||||
return jsonify(result)
|
return jsonify(result)
|
||||||
|
|
||||||
@app.route("/api/mission/log", methods=["GET"])
|
@app.route("/api/mission/log", methods=["GET"])
|
||||||
@@ -1574,31 +1781,61 @@ def api_qr_read_angles(qr_id):
|
|||||||
|
|
||||||
@app.route("/api/qr/scan/<qr_id>", methods=["POST"])
|
@app.route("/api/qr/scan/<qr_id>", methods=["POST"])
|
||||||
def api_qr_config_scan(qr_id):
|
def api_qr_config_scan(qr_id):
|
||||||
"""获取机械臂摄像头图像,识别二维码并保存到指定配置项"""
|
"""获取机械臂摄像头图像,识别二维码并保存到指定配置项(pyzbar 优先,OpenCV 兜底)"""
|
||||||
import requests
|
import requests
|
||||||
try:
|
try:
|
||||||
|
jpg_bytes = None
|
||||||
|
# 多次尝试获取清晰帧
|
||||||
|
for _ in range(3):
|
||||||
|
try:
|
||||||
|
r = requests.get(ARM_CAMERA_CONFIG.get("snapshot_url", ARM_CAMERA_CONFIG["url"]), timeout=8)
|
||||||
|
if r.status_code == 200 and r.content:
|
||||||
|
jpg_bytes = r.content
|
||||||
|
break
|
||||||
|
except:
|
||||||
|
pass
|
||||||
|
import time; time.sleep(0.3)
|
||||||
|
|
||||||
|
if jpg_bytes is None:
|
||||||
|
return jsonify({"ok": False, "error": "无法连接机械臂摄像头"}), 400
|
||||||
|
|
||||||
import cv2
|
import cv2
|
||||||
import numpy as np
|
import numpy as np
|
||||||
# 从机械臂摄像头 snapshot 端点拉取一帧 JPEG
|
|
||||||
r = requests.get(ARM_CAMERA_CONFIG.get("snapshot_url", ARM_CAMERA_CONFIG["url"]), timeout=8)
|
|
||||||
if r.status_code != 200 or not r.content:
|
|
||||||
return jsonify({"ok": False, "error": "无法连接机械臂摄像头"}), 400
|
|
||||||
jpg_bytes = r.content
|
|
||||||
# 解码为 numpy 数组并检测二维码
|
|
||||||
nparr = np.frombuffer(jpg_bytes, np.uint8)
|
nparr = np.frombuffer(jpg_bytes, np.uint8)
|
||||||
frame = cv2.imdecode(nparr, cv2.IMREAD_COLOR)
|
frame = cv2.imdecode(nparr, cv2.IMREAD_COLOR)
|
||||||
if frame is None:
|
if frame is None:
|
||||||
return jsonify({"ok": False, "error": "图像解码失败"}), 400
|
return jsonify({"ok": False, "error": "图像解码失败"}), 400
|
||||||
# 使用 OpenCV QRCodeDetector 检测
|
|
||||||
detector = cv2.QRCodeDetector()
|
result = None
|
||||||
result, _, _ = detector.detectAndDecode(frame)
|
|
||||||
if result and len(result.strip()) > 0:
|
# 方法1: pyzbar(识别率更高)
|
||||||
result = result.strip()
|
try:
|
||||||
|
from PIL import Image
|
||||||
|
from pyzbar.pyzbar import decode as pyzbar_decode
|
||||||
|
pil_img = Image.fromarray(cv2.cvtColor(frame, cv2.COLOR_BGR2RGB))
|
||||||
|
codes = pyzbar_decode(pil_img)
|
||||||
|
if codes and codes[0].data:
|
||||||
|
result = codes[0].data.decode("utf-8").strip()
|
||||||
|
logger.info(f"pyzbar 扫码成功: {result}")
|
||||||
|
except Exception as e:
|
||||||
|
logger.debug(f"pyzbar 扫码失败: {e}")
|
||||||
|
|
||||||
|
# 方法2: OpenCV QRCodeDetector(兜底)
|
||||||
|
if not result:
|
||||||
|
try:
|
||||||
|
detector = cv2.QRCodeDetector()
|
||||||
|
val, _, _ = detector.detectAndDecode(frame)
|
||||||
|
if val and len(val.strip()) > 0:
|
||||||
|
result = val.strip()
|
||||||
|
logger.info(f"OpenCV 扫码成功: {result}")
|
||||||
|
except Exception as e:
|
||||||
|
logger.debug(f"OpenCV 扫码失败: {e}")
|
||||||
|
|
||||||
|
if result:
|
||||||
# 保存到配置项
|
# 保存到配置项
|
||||||
for entry in gs.qr_config:
|
for entry in gs.qr_config:
|
||||||
if entry["id"] == qr_id:
|
if entry["id"] == qr_id:
|
||||||
entry["qr_value"] = result
|
entry["qr_value"] = result
|
||||||
# 尝试匹配机型
|
|
||||||
matched_model = None
|
matched_model = None
|
||||||
for model in gs.models_config:
|
for model in gs.models_config:
|
||||||
prefix = model.get("serial_prefix", "")
|
prefix = model.get("serial_prefix", "")
|
||||||
@@ -1615,19 +1852,353 @@ def api_qr_config_scan(qr_id):
|
|||||||
"model_name": matched_model["name"] if matched_model else ""
|
"model_name": matched_model["name"] if matched_model else ""
|
||||||
})
|
})
|
||||||
return jsonify({"ok": False, "error": f"二维码 {qr_id} 不存在"}), 404
|
return jsonify({"ok": False, "error": f"二维码 {qr_id} 不存在"}), 404
|
||||||
else:
|
|
||||||
return jsonify({"ok": False, "error": "未检测到二维码"})
|
return jsonify({"ok": False, "error": "未检测到二维码,请调整机械臂姿态或手动输入"})
|
||||||
except Exception as ex:
|
except Exception as ex:
|
||||||
logger.error(f"QR 扫描机械臂摄像头失败: {ex}")
|
logger.error(f"QR 扫描失败: {ex}")
|
||||||
return jsonify({"ok": False, "error": f"扫描失败: {str(ex)}"}), 400
|
return jsonify({"ok": False, "error": f"扫描失败: {str(ex)}"}), 400
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
# ========== 环境切换 API ==========
|
||||||
|
|
||||||
|
@app.route("/api/config/mode", methods=["GET"])
|
||||||
|
def api_config_mode_get():
|
||||||
|
"""获取当前 API 环境模式"""
|
||||||
|
import config
|
||||||
|
return jsonify({
|
||||||
|
"ok": True,
|
||||||
|
"test_mode": config.TEST_MODE,
|
||||||
|
"base_url": config.ZHIJIAN_BASE_URL,
|
||||||
|
"label": "测试环境" if config.TEST_MODE else "正式环境"
|
||||||
|
})
|
||||||
|
|
||||||
|
|
||||||
|
@app.route("/api/config/mode", methods=["POST"])
|
||||||
|
def api_config_mode_set():
|
||||||
|
"""切换 API 环境"""
|
||||||
|
body = request.get_json(silent=True) or {}
|
||||||
|
test_mode = body.get("test_mode", True)
|
||||||
|
set_api_mode(test_mode)
|
||||||
|
import config
|
||||||
|
logger.info(f"API 环境已切换为: {'测试' if test_mode else '正式'} → {config.ZHIJIAN_BASE_URL}")
|
||||||
|
return jsonify({
|
||||||
|
"ok": True,
|
||||||
|
"test_mode": config.TEST_MODE,
|
||||||
|
"base_url": config.ZHIJIAN_BASE_URL,
|
||||||
|
"label": "测试环境" if config.TEST_MODE else "正式环境"
|
||||||
|
})
|
||||||
|
|
||||||
|
|
||||||
|
# ========== 报关单接口(代理外部 API) ==========
|
||||||
|
|
||||||
|
def _get_zhijian_base():
|
||||||
|
"""动态获取报关单 API base,跟随环境切换"""
|
||||||
|
import config
|
||||||
|
base = f"{config.ZHIJIAN_BASE_URL}{config.API_PREFIX}/zhijian/integration"
|
||||||
|
return base
|
||||||
|
|
||||||
|
_ZHIJIAN_AUTH = ZHIJIAN_AUTH_TOKEN
|
||||||
|
|
||||||
|
|
||||||
|
@app.route("/api/customs/list")
|
||||||
|
def api_customs_list():
|
||||||
|
"""获取报关单列表(代理外部 API)"""
|
||||||
|
import requests
|
||||||
|
page = request.args.get("pageNum", 1)
|
||||||
|
size = request.args.get("pageSize", 50)
|
||||||
|
url = f"{_get_zhijian_base()}/customsListPage?pageNum={page}&pageSize={size}"
|
||||||
|
logger.info(f"[customs/list] 🔍 请求 → {url}")
|
||||||
|
try:
|
||||||
|
r = requests.get(url, headers={"Authorization": _ZHIJIAN_AUTH}, timeout=15)
|
||||||
|
logger.info(f"[customs/list] 📡 响应 HTTP {r.status_code}, body长度={len(r.text)}")
|
||||||
|
if r.status_code != 200:
|
||||||
|
logger.warning(f"[customs/list] ⚠️ 返回非200: {r.status_code}")
|
||||||
|
return jsonify({"ok": False, "error": f"报关单API返回 {r.status_code}"}), 502
|
||||||
|
data = r.json()
|
||||||
|
# 兼容不同返回格式:裸数组 / {data:{rows:[...]}} / {rows:[...]}
|
||||||
|
if isinstance(data, list):
|
||||||
|
rows = data
|
||||||
|
elif isinstance(data, dict):
|
||||||
|
rows = data.get("data", {}).get("rows", []) or data.get("rows", [])
|
||||||
|
else:
|
||||||
|
rows = []
|
||||||
|
logger.info(f"[customs/list] ✅ 获取到 {len(rows)} 条报关单")
|
||||||
|
return jsonify({"ok": True, "data": {"rows": rows}})
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"[customs/list] ❌ 失败: {e}")
|
||||||
|
return jsonify({"ok": False, "error": str(e)}), 502
|
||||||
|
|
||||||
|
|
||||||
|
@app.route("/api/customs/machines")
|
||||||
|
def api_customs_machines():
|
||||||
|
"""根据报关单 ID 获取机器列表(代理外部 API)
|
||||||
|
数据源:cjt_customs_item 表 → Java customsMachines 接口
|
||||||
|
"""
|
||||||
|
import requests
|
||||||
|
customs_id = request.args.get("customsId", "")
|
||||||
|
logger.info(f"[customs/machines] 📥 customsId={customs_id}")
|
||||||
|
if not customs_id:
|
||||||
|
logger.warning("[customs/machines] ⚠️ 缺少 customsId")
|
||||||
|
return jsonify({"ok": False, "error": "缺少 customsId 参数"}), 400
|
||||||
|
|
||||||
|
try:
|
||||||
|
url = f"{_get_zhijian_base()}/customsMachines?customsId={customs_id}"
|
||||||
|
logger.info(f"[customs/machines] 🔍 请求 → {url}")
|
||||||
|
r = requests.get(url, headers={"Authorization": _ZHIJIAN_AUTH}, timeout=15)
|
||||||
|
logger.info(f"[customs/machines] 📡 响应 HTTP {r.status_code}, body长度={len(r.text)}")
|
||||||
|
if r.status_code != 200:
|
||||||
|
logger.warning(f"[customs/machines] ⚠️ 返回非200: {r.status_code}, body={r.text[:300]}")
|
||||||
|
return jsonify({"ok": False, "error": "机器列表API返回非200"}), 502
|
||||||
|
result = r.json()
|
||||||
|
machines = result.get("data") or []
|
||||||
|
logger.info(f"[customs/machines] ✅ 获取到 {len(machines)} 条机器记录")
|
||||||
|
return jsonify({"ok": True, "data": result})
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"[customs/machines] ❌ 失败: {e}")
|
||||||
|
return jsonify({"ok": False, "error": str(e)}), 502
|
||||||
|
|
||||||
|
|
||||||
|
@app.route("/api/customs/selected", methods=["POST"])
|
||||||
|
def api_customs_selected():
|
||||||
|
"""任务开始前设定当前报关单(存储当前任务对应的报关单信息)"""
|
||||||
|
data = request.json or {}
|
||||||
|
gs.current_customs = {
|
||||||
|
"id": data.get("id", ""),
|
||||||
|
"name": data.get("name", ""),
|
||||||
|
"machine_ids": data.get("machine_ids", []),
|
||||||
|
}
|
||||||
|
logger.info(f"设定报关单: {gs.current_customs['name']} ({len(gs.current_customs['machine_ids'])} 台机器)")
|
||||||
|
return jsonify({"ok": True, "customs": gs.current_customs})
|
||||||
|
|
||||||
|
|
||||||
|
@app.route("/api/customs/selected", methods=["GET"])
|
||||||
|
def api_customs_selected_get():
|
||||||
|
"""获取当前设定的报关单信息"""
|
||||||
|
c = gs.current_customs or {"id": "", "name": "未选择", "machine_ids": []}
|
||||||
|
return jsonify({"ok": True, "customs": c})
|
||||||
|
|
||||||
|
|
||||||
|
# ========== 查验 API ==========
|
||||||
|
@app.route("/api/customs/inspection/start", methods=["POST"])
|
||||||
|
def api_customs_inspection_start():
|
||||||
|
"""开始查验:加载报关单机器列表,初始化查验计数"""
|
||||||
|
data = request.json or {}
|
||||||
|
customs_id = data.get("customsId", "")
|
||||||
|
if not customs_id:
|
||||||
|
return jsonify({"ok": False, "error": "缺少 customsId"}), 400
|
||||||
|
|
||||||
|
# 获取报关单机器列表
|
||||||
|
try:
|
||||||
|
machines_url = f"{_get_zhijian_base()}/customsMachines?customsId={customs_id}"
|
||||||
|
logger.info(f"[inspection/start] 🔍 获取机器列表 → {machines_url}")
|
||||||
|
r = requests.get(
|
||||||
|
machines_url,
|
||||||
|
headers={"Authorization": _ZHIJIAN_AUTH},
|
||||||
|
timeout=15
|
||||||
|
)
|
||||||
|
logger.info(f"[inspection/start] 📡 机器列表响应 HTTP {r.status_code}, body长度={len(r.text)}")
|
||||||
|
if r.status_code != 200:
|
||||||
|
logger.warning(f"[inspection/start] ⚠️ 返回非200: {r.status_code}, body={r.text[:300]}")
|
||||||
|
return jsonify({"ok": False, "error": f"接口返回 {r.status_code}"}), 502
|
||||||
|
j = r.json()
|
||||||
|
machines = j.get("data") or []
|
||||||
|
logger.info(f"[inspection/start] ✅ 获取到 {len(machines)} 条机器记录")
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"[inspection/start] ❌ 获取机器列表失败: {e}")
|
||||||
|
return jsonify({"ok": False, "error": str(e)}), 502
|
||||||
|
|
||||||
|
# 按 inventoryCode 聚合(同一物料可能有多条序列号记录)
|
||||||
|
items_dict = {}
|
||||||
|
for m in machines:
|
||||||
|
code = m.get("inventoryCode") or m.get("machineCode") or "unknown"
|
||||||
|
if code not in items_dict:
|
||||||
|
items_dict[code] = {
|
||||||
|
"inventoryCode": code,
|
||||||
|
"inventoryName": m.get("inventoryName") or m.get("machineName") or "-",
|
||||||
|
"spec": m.get("inventorySpecification") or m.get("spec") or "-",
|
||||||
|
"quantify": 0,
|
||||||
|
"inspected": 0,
|
||||||
|
}
|
||||||
|
# 累计数量(quantify 字段可能来自 customsMachines 的返回值)
|
||||||
|
q = m.get("quantify", 0)
|
||||||
|
if q:
|
||||||
|
items_dict[code]["quantify"] += int(float(q))
|
||||||
|
|
||||||
|
# 如果 quantify 全部为 0,用机器条目数作为数量
|
||||||
|
for item in items_dict.values():
|
||||||
|
if item["quantify"] == 0:
|
||||||
|
item["quantify"] = sum(1 for m in machines if (m.get("inventoryCode") or "") == item["inventoryCode"])
|
||||||
|
|
||||||
|
logger.info(f"[inspection/start] 📊 聚合结果: {len(items_dict)} 种机型, total {sum(i['quantify'] for i in items_dict.values())} 台")
|
||||||
|
|
||||||
|
# 获取报关单名称
|
||||||
|
customs_name = data.get("customsName") or customs_id
|
||||||
|
try:
|
||||||
|
list_url = f"{_get_zhijian_base()}/customsListPage?pageNum=1&pageSize=100"
|
||||||
|
logger.info(f"[inspection/start] 🔍 获取报关单名称 → {list_url}")
|
||||||
|
r2 = requests.get(
|
||||||
|
list_url,
|
||||||
|
headers={"Authorization": _ZHIJIAN_AUTH},
|
||||||
|
timeout=15
|
||||||
|
)
|
||||||
|
if r2.status_code == 200:
|
||||||
|
j2 = r2.json()
|
||||||
|
for row in j2.get("rows", []):
|
||||||
|
c = row.get("customs", {})
|
||||||
|
if str(c.get("id", "")) == str(customs_id):
|
||||||
|
customs_name = c.get("customsCode") or row.get("orderCode") or customs_name
|
||||||
|
break
|
||||||
|
except:
|
||||||
|
pass
|
||||||
|
|
||||||
|
gs.inspection = {
|
||||||
|
"customsId": customs_id,
|
||||||
|
"customsName": customs_name,
|
||||||
|
"items": list(items_dict.values()),
|
||||||
|
"startedAt": time.time(),
|
||||||
|
}
|
||||||
|
logger.info(f"开始查验: {customs_name} ({len(gs.inspection['items'])} 种机型,共 {sum(i['quantify'] for i in gs.inspection['items'])} 台)")
|
||||||
|
return jsonify({"ok": True, "inspection": gs.inspection})
|
||||||
|
|
||||||
|
|
||||||
|
@app.route("/api/customs/inspection", methods=["GET"])
|
||||||
|
def api_customs_inspection():
|
||||||
|
"""获取当前查验状态"""
|
||||||
|
return jsonify({"ok": True, "inspection": gs.inspection})
|
||||||
|
|
||||||
|
|
||||||
|
@app.route("/api/customs/inspection/end", methods=["POST"])
|
||||||
|
def api_customs_inspection_end():
|
||||||
|
"""结束查验"""
|
||||||
|
gs.inspection = None
|
||||||
|
return jsonify({"ok": True})
|
||||||
|
|
||||||
|
|
||||||
|
@app.route("/api/customs/printer", methods=["GET"])
|
||||||
|
def api_customs_printer():
|
||||||
|
"""代理 /zhijian/profile/printer 查询,同时更新查验计数
|
||||||
|
GET ?serialNumber=xxx
|
||||||
|
"""
|
||||||
|
sn = request.args.get("serialNumber", "").strip()
|
||||||
|
logger.info(f"[printer] 📥 收到查询请求 serialNumber={sn}")
|
||||||
|
if not sn:
|
||||||
|
logger.warning("[printer] ⚠️ 缺少 serialNumber 参数")
|
||||||
|
return jsonify({"ok": False, "error": "缺少 serialNumber"}), 400
|
||||||
|
|
||||||
|
# 调用 Java profile/printer 接口
|
||||||
|
api_base = _get_zhijian_base().rstrip("/")
|
||||||
|
profile_url = f"{api_base[:api_base.rfind('/')]}/profile/printer?serialNumber={sn}"
|
||||||
|
logger.info(f"[printer] 🔍 请求 Java → {profile_url}")
|
||||||
|
try:
|
||||||
|
r = requests.get(profile_url, headers={"Authorization": _ZHIJIAN_AUTH}, timeout=15)
|
||||||
|
logger.info(f"[printer] 📡 Java响应 HTTP {r.status_code}: {r.text[:500]}")
|
||||||
|
if r.status_code != 200:
|
||||||
|
logger.warning(f"[printer] ⚠️ Java返回非200: {r.status_code}")
|
||||||
|
return jsonify({"ok": False, "error": f"profile/printer 返回 {r.status_code}"}), 502
|
||||||
|
j = r.json()
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"[printer] ❌ 查询 Java printer 失败: {e}")
|
||||||
|
return jsonify({"ok": False, "error": str(e)}), 502
|
||||||
|
|
||||||
|
data = j.get("data", j)
|
||||||
|
printer = data.get("printer")
|
||||||
|
order_item = data.get("orderItem")
|
||||||
|
logger.info(f"[printer] 📊 Java返回解析: printer={'yes' if printer else 'no'}, orderItem={'yes' if order_item else 'no'}")
|
||||||
|
|
||||||
|
result = {
|
||||||
|
"ok": True,
|
||||||
|
"printer": printer,
|
||||||
|
"orderItem": order_item,
|
||||||
|
"modelName": "机器1", # 默认
|
||||||
|
"inventoryCode": None,
|
||||||
|
"matchedItem": None,
|
||||||
|
"hasInspection": gs.inspection is not None,
|
||||||
|
}
|
||||||
|
|
||||||
|
# 提取 inventory 信息(优先级: orderItem.inventory > printer.inventory > printer.model/machineModel)
|
||||||
|
if order_item and order_item.get("inventory"):
|
||||||
|
inv = order_item["inventory"]
|
||||||
|
result["modelName"] = inv.get("inventoryName") or inv.get("name") or "机器1"
|
||||||
|
result["inventoryCode"] = inv.get("inventoryCode") or inv.get("code")
|
||||||
|
logger.info(f"[printer] 🏷️ 从 orderItem.inventory 提取: modelName={result['modelName']}, inventoryCode={result['inventoryCode']}")
|
||||||
|
elif printer and printer.get("inventory"):
|
||||||
|
inv = printer["inventory"]
|
||||||
|
result["modelName"] = inv.get("inventoryName") or inv.get("name") or "机器1"
|
||||||
|
result["inventoryCode"] = inv.get("inventoryCode") or inv.get("code")
|
||||||
|
logger.info(f"[printer] 🏷️ 从 printer.inventory 提取: modelName={result['modelName']}, inventoryCode={result['inventoryCode']}")
|
||||||
|
elif printer:
|
||||||
|
result["modelName"] = printer.get("model") or printer.get("machineModel") or "机器1"
|
||||||
|
logger.info(f"[printer] 🏷️ 从 printer.model/machineModel 提取: modelName={result['modelName']}")
|
||||||
|
else:
|
||||||
|
logger.warning(f"[printer] ⚠️ printer 和 orderItem 均为空,回退 modelName=机器1")
|
||||||
|
|
||||||
|
# 更新查验计数(先检查是否超量,超量时不增加计数)
|
||||||
|
if gs.inspection and result["inventoryCode"]:
|
||||||
|
for item in gs.inspection["items"]:
|
||||||
|
if item["inventoryCode"] == result["inventoryCode"]:
|
||||||
|
# 先返回当前值(未+1),让调用方判断是否超量
|
||||||
|
result["matchedItem"] = item
|
||||||
|
# 只有未超量时才真正+1
|
||||||
|
if item["inspected"] < item["quantify"]:
|
||||||
|
item["inspected"] += 1
|
||||||
|
logger.info(f"[printer] ✅ 查验计数更新: {item['inventoryName']} → {item['inspected']}/{item['quantify']}")
|
||||||
|
else:
|
||||||
|
logger.warning(f"[printer] ⚠️ 超量不计数: {item['inventoryName']} 已达 {item['inspected']}/{item['quantify']}")
|
||||||
|
break
|
||||||
|
else:
|
||||||
|
logger.warning(f"[printer] ⚠️ inventoryCode={result['inventoryCode']} 不在查验清单中")
|
||||||
|
logger.info(f"[printer] 📤 返回结果: modelName={result['modelName']}, inventoryCode={result['inventoryCode']}, hasInspection={result['hasInspection']}, matched={'yes' if result['matchedItem'] else 'no'}")
|
||||||
|
return jsonify(result)
|
||||||
|
|
||||||
|
|
||||||
|
@app.route("/api/customs/inspection/update", methods=["POST"])
|
||||||
|
def api_customs_inspection_update():
|
||||||
|
"""直接更新查验计数(由执行器调用)"""
|
||||||
|
data = request.json or {}
|
||||||
|
code = data.get("inventoryCode", "")
|
||||||
|
if not gs.inspection or not code:
|
||||||
|
return jsonify({"ok": False})
|
||||||
|
for item in gs.inspection["items"]:
|
||||||
|
if item["inventoryCode"] == code:
|
||||||
|
item["inspected"] += 1
|
||||||
|
return jsonify({"ok": True, "item": item})
|
||||||
|
return jsonify({"ok": False, "error": "未匹配"})
|
||||||
|
|
||||||
|
|
||||||
# ========== 静态资源 ==========
|
# ========== 静态资源 ==========
|
||||||
@app.route("/photos/<name>")
|
@app.route("/photos/<name>")
|
||||||
def photos(name):
|
def photos(name):
|
||||||
return send_from_directory(os.path.join(DATA_DIR, "photos"), name)
|
return send_from_directory(os.path.join(DATA_DIR, "photos"), name)
|
||||||
|
|
||||||
|
|
||||||
|
@app.route("/api/camera/refresh")
|
||||||
|
def api_camera_refresh():
|
||||||
|
"""AGV 摄像头单帧 JPEG(polling 模式)"""
|
||||||
|
if not gs.qr_scanner:
|
||||||
|
return jsonify({"error": "scanner not initialized"}), 400
|
||||||
|
if not gs.camera_opened:
|
||||||
|
return jsonify({"error": "camera not opened"}), 400
|
||||||
|
import cv2
|
||||||
|
frame = gs.qr_scanner.read_frame()
|
||||||
|
if frame is None:
|
||||||
|
return jsonify({"error": "read frame failed"}), 400
|
||||||
|
# 检查是否为全黑/无内容的帧(Orbbec 深度/IR 帧可能无内容)
|
||||||
|
if frame.mean() < 5:
|
||||||
|
return jsonify({"error": "camera sensor not ready"}), 400
|
||||||
|
ret, buf = cv2.imencode(".jpg", frame)
|
||||||
|
if ret:
|
||||||
|
return Response(buf.tobytes(), mimetype="image/jpeg")
|
||||||
|
return jsonify({"error": "encode failed"}), 500
|
||||||
|
|
||||||
|
|
||||||
|
@app.route("/api/camera/capabilities")
|
||||||
|
def api_camera_capabilities():
|
||||||
|
"""返回摄像头能力信息,前端据此决定如何展示"""
|
||||||
|
return jsonify({
|
||||||
|
"has_agv_camera": bool(gs.camera_opened),
|
||||||
|
"has_arm_camera": bool(gs.arm_camera_opened),
|
||||||
|
})
|
||||||
# ========== 启动 ==========
|
# ========== 启动 ==========
|
||||||
if __name__ == "__main__":
|
if __name__ == "__main__":
|
||||||
logger.info("=" * 50)
|
logger.info("=" * 50)
|
||||||
|
|||||||
+33
-7
@@ -7,15 +7,20 @@ import os
|
|||||||
BASE_DIR = os.path.dirname(os.path.abspath(__file__))
|
BASE_DIR = os.path.dirname(os.path.abspath(__file__))
|
||||||
|
|
||||||
# ========== 网络配置(集中管理所有 IP 地址 — 修改此处即可全局生效)==========
|
# ========== 网络配置(集中管理所有 IP 地址 — 修改此处即可全局生效)==========
|
||||||
AGV_HOST = "192.168.60.177"
|
AGV_HOST = "192.168.60.80"
|
||||||
ARM_HOST = "192.168.60.88"
|
ARM_HOST = "192.168.60.120"
|
||||||
|
|
||||||
|
# ========== 开发模式 ==========
|
||||||
|
# 设置为 True 时使用 Mock 硬件实现(无需真实硬件)
|
||||||
|
# 通过环境变量 MOCK_HARDWARE=1 启用
|
||||||
|
MOCK_HARDWARE = os.getenv("MOCK_HARDWARE", "0") == "1"
|
||||||
|
|
||||||
# ========== AGV 参数 ==========
|
# ========== AGV 参数 ==========
|
||||||
AGV_CONFIG = {
|
AGV_CONFIG = {
|
||||||
"device": "/dev/agvpro_controller",
|
"device": "/dev/agvpro_controller",
|
||||||
"baudrate": 10000000,
|
"baudrate": 10000000,
|
||||||
"move_speed": 0.5,
|
"move_speed": 1.0,
|
||||||
"turn_speed": 0.5,
|
"turn_speed": 1.0,
|
||||||
}
|
}
|
||||||
|
|
||||||
# ========== 机械臂 TCP 客户端 ==========
|
# ========== 机械臂 TCP 客户端 ==========
|
||||||
@@ -35,7 +40,7 @@ MAP_CONFIG = {
|
|||||||
|
|
||||||
# ========== 摄像头 ==========
|
# ========== 摄像头 ==========
|
||||||
CAMERA_CONFIG = {
|
CAMERA_CONFIG = {
|
||||||
"device_index": 4, # AGV 摄像头 video4(标准彩色摄像头,V4L2后端)
|
"device_index": 4, # AGV 摄像头 video4(Orbbec Gemini 彩色流,YUYV 640x480)
|
||||||
"backend": "v4l2", # 使用 V4L2 后端获取标准彩色格式(640x480)
|
"backend": "v4l2", # 使用 V4L2 后端获取标准彩色格式(640x480)
|
||||||
"qr_detect_interval": 0.5,
|
"qr_detect_interval": 0.5,
|
||||||
"capture_delay": 0.5,
|
"capture_delay": 0.5,
|
||||||
@@ -47,9 +52,30 @@ ARM_CAMERA_CONFIG = {
|
|||||||
"snapshot_url": f"http://{ARM_HOST}:5003/api/camera/snapshot",
|
"snapshot_url": f"http://{ARM_HOST}:5003/api/camera/snapshot",
|
||||||
}
|
}
|
||||||
|
|
||||||
|
# ========== 外部 API 环境 ==========
|
||||||
|
# 切换测试/正式环境只需改 TEST_MODE 一个变量
|
||||||
|
TEST_MODE = False # True=测试环境(192.168.60.159), False=正式环境(ts.zhijian168.com)
|
||||||
|
PROD_BASE_URL = "https://ts.zhijian168.com"
|
||||||
|
TEST_BASE_URL = "http://192.168.60.159:8080"
|
||||||
|
PROD_API_PREFIX = "/prod-api"
|
||||||
|
TEST_API_PREFIX = "" # 测试服务器无 /prod-api 网关前缀
|
||||||
|
ZHIJIAN_BASE_URL = TEST_BASE_URL if TEST_MODE else PROD_BASE_URL
|
||||||
|
API_PREFIX = TEST_API_PREFIX if TEST_MODE else PROD_API_PREFIX
|
||||||
|
ZHIJIAN_AUTH_TOKEN = "Bearer eyJhbGciOiJIUzUxMiJ9.eyJ1c2VyX2lkIjoxLCJ1c2VyX2tleSI6ImZhNTNkZTZiLWE3NjYtNDZmNC05MDUyLTQ2MjUzZTAyNjdmNSIsInVzZXJuYW1lIjoiYWRtaW4ifQ.lC4vKThZo4aAOLsekm2kPgaEJRqRx-YDQWKfHFqxdPNESCKy57l3eIqaKTj2ZjAMaoYAwYlMrv5M1zAOJsO_PA"
|
||||||
|
|
||||||
|
|
||||||
|
def set_api_mode(test_mode):
|
||||||
|
"""运行时切换 API 环境 — 无需重启 Flask"""
|
||||||
|
global TEST_MODE, ZHIJIAN_BASE_URL, API_PREFIX, UPLOAD_CONFIG
|
||||||
|
TEST_MODE = bool(test_mode)
|
||||||
|
ZHIJIAN_BASE_URL = TEST_BASE_URL if TEST_MODE else PROD_BASE_URL
|
||||||
|
API_PREFIX = TEST_API_PREFIX if TEST_MODE else PROD_API_PREFIX
|
||||||
|
UPLOAD_CONFIG["url"] = f"{ZHIJIAN_BASE_URL}{API_PREFIX}/file/uploadImage"
|
||||||
|
|
||||||
|
|
||||||
# ========== HTTP 上传 ==========
|
# ========== HTTP 上传 ==========
|
||||||
UPLOAD_CONFIG = {
|
UPLOAD_CONFIG = {
|
||||||
"url": "https://ts.zhijian168.com/prod-api/file/uploadImage",
|
"url": f"{ZHIJIAN_BASE_URL}{API_PREFIX}/file/uploadImage",
|
||||||
"timeout": 30,
|
"timeout": 30,
|
||||||
"max_retries": 3,
|
"max_retries": 3,
|
||||||
}
|
}
|
||||||
@@ -77,7 +103,7 @@ JOINT_LIMITS = {
|
|||||||
}
|
}
|
||||||
|
|
||||||
# ========== 机械臂默认速度 ==========
|
# ========== 机械臂默认速度 ==========
|
||||||
DEFAULT_ARM_SPEED = 500
|
DEFAULT_ARM_SPEED = 1000
|
||||||
|
|
||||||
# ========== 状态定义 ==========
|
# ========== 状态定义 ==========
|
||||||
class State:
|
class State:
|
||||||
|
|||||||
+212
-146
@@ -1,25 +1,4 @@
|
|||||||
[
|
[
|
||||||
{
|
|
||||||
"id": "m_2_0",
|
|
||||||
"row": 2,
|
|
||||||
"col": 0,
|
|
||||||
"front": {
|
|
||||||
"coords": [
|
|
||||||
0,
|
|
||||||
0,
|
|
||||||
0
|
|
||||||
],
|
|
||||||
"poses": []
|
|
||||||
},
|
|
||||||
"back": {
|
|
||||||
"coords": [
|
|
||||||
0,
|
|
||||||
0,
|
|
||||||
0
|
|
||||||
],
|
|
||||||
"poses": []
|
|
||||||
}
|
|
||||||
},
|
|
||||||
{
|
{
|
||||||
"id": "m_2_2",
|
"id": "m_2_2",
|
||||||
"row": 2,
|
"row": 2,
|
||||||
@@ -167,27 +146,6 @@
|
|||||||
"poses": []
|
"poses": []
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
{
|
|
||||||
"id": "m_0_4",
|
|
||||||
"row": 0,
|
|
||||||
"col": 4,
|
|
||||||
"front": {
|
|
||||||
"coords": [
|
|
||||||
0,
|
|
||||||
0,
|
|
||||||
0
|
|
||||||
],
|
|
||||||
"poses": []
|
|
||||||
},
|
|
||||||
"back": {
|
|
||||||
"coords": [
|
|
||||||
0,
|
|
||||||
0,
|
|
||||||
0
|
|
||||||
],
|
|
||||||
"poses": []
|
|
||||||
}
|
|
||||||
},
|
|
||||||
{
|
{
|
||||||
"id": "m_0_5",
|
"id": "m_0_5",
|
||||||
"row": 0,
|
"row": 0,
|
||||||
@@ -251,27 +209,6 @@
|
|||||||
"poses": []
|
"poses": []
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
{
|
|
||||||
"id": "m_2_1",
|
|
||||||
"row": 2,
|
|
||||||
"col": 1,
|
|
||||||
"front": {
|
|
||||||
"coords": [
|
|
||||||
0,
|
|
||||||
0,
|
|
||||||
0
|
|
||||||
],
|
|
||||||
"poses": []
|
|
||||||
},
|
|
||||||
"back": {
|
|
||||||
"coords": [
|
|
||||||
0,
|
|
||||||
0,
|
|
||||||
0
|
|
||||||
],
|
|
||||||
"poses": []
|
|
||||||
}
|
|
||||||
},
|
|
||||||
{
|
{
|
||||||
"id": "m_2_4",
|
"id": "m_2_4",
|
||||||
"row": 2,
|
"row": 2,
|
||||||
@@ -293,27 +230,6 @@
|
|||||||
"poses": []
|
"poses": []
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
{
|
|
||||||
"id": "m_2_5",
|
|
||||||
"row": 2,
|
|
||||||
"col": 5,
|
|
||||||
"front": {
|
|
||||||
"coords": [
|
|
||||||
0,
|
|
||||||
0,
|
|
||||||
0
|
|
||||||
],
|
|
||||||
"poses": []
|
|
||||||
},
|
|
||||||
"back": {
|
|
||||||
"coords": [
|
|
||||||
0,
|
|
||||||
0,
|
|
||||||
0
|
|
||||||
],
|
|
||||||
"poses": []
|
|
||||||
}
|
|
||||||
},
|
|
||||||
{
|
{
|
||||||
"id": "m_0_0",
|
"id": "m_0_0",
|
||||||
"row": 0,
|
"row": 0,
|
||||||
@@ -356,36 +272,6 @@
|
|||||||
"poses": []
|
"poses": []
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
{
|
|
||||||
"id": "m_0_1",
|
|
||||||
"row": 0,
|
|
||||||
"col": 1,
|
|
||||||
"front": {
|
|
||||||
"coords": [
|
|
||||||
0,
|
|
||||||
0,
|
|
||||||
0
|
|
||||||
],
|
|
||||||
"poses": []
|
|
||||||
},
|
|
||||||
"back": {
|
|
||||||
"coords": [
|
|
||||||
0,
|
|
||||||
0,
|
|
||||||
0
|
|
||||||
],
|
|
||||||
"poses": []
|
|
||||||
},
|
|
||||||
"qr": {
|
|
||||||
"coords": [
|
|
||||||
0,
|
|
||||||
0,
|
|
||||||
0
|
|
||||||
],
|
|
||||||
"qr_value": "",
|
|
||||||
"model_id": ""
|
|
||||||
}
|
|
||||||
},
|
|
||||||
{
|
{
|
||||||
"id": "m_0_2",
|
"id": "m_0_2",
|
||||||
"row": 0,
|
"row": 0,
|
||||||
@@ -416,36 +302,6 @@
|
|||||||
"model_id": ""
|
"model_id": ""
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
{
|
|
||||||
"id": "m_0_3",
|
|
||||||
"row": 0,
|
|
||||||
"col": 3,
|
|
||||||
"front": {
|
|
||||||
"coords": [
|
|
||||||
0,
|
|
||||||
0,
|
|
||||||
0
|
|
||||||
],
|
|
||||||
"poses": []
|
|
||||||
},
|
|
||||||
"back": {
|
|
||||||
"coords": [
|
|
||||||
0,
|
|
||||||
0,
|
|
||||||
0
|
|
||||||
],
|
|
||||||
"poses": []
|
|
||||||
},
|
|
||||||
"qr": {
|
|
||||||
"coords": [
|
|
||||||
0,
|
|
||||||
0,
|
|
||||||
0
|
|
||||||
],
|
|
||||||
"qr_value": "",
|
|
||||||
"model_id": ""
|
|
||||||
}
|
|
||||||
},
|
|
||||||
{
|
{
|
||||||
"id": "m_1_1",
|
"id": "m_1_1",
|
||||||
"row": 1,
|
"row": 1,
|
||||||
@@ -506,6 +362,126 @@
|
|||||||
"model_id": ""
|
"model_id": ""
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
"id": "m_1_4",
|
||||||
|
"row": 1,
|
||||||
|
"col": 4,
|
||||||
|
"front": {
|
||||||
|
"coords": [
|
||||||
|
0,
|
||||||
|
0,
|
||||||
|
0
|
||||||
|
],
|
||||||
|
"poses": []
|
||||||
|
},
|
||||||
|
"back": {
|
||||||
|
"coords": [
|
||||||
|
0,
|
||||||
|
0,
|
||||||
|
0
|
||||||
|
],
|
||||||
|
"poses": []
|
||||||
|
},
|
||||||
|
"qr": {
|
||||||
|
"coords": [
|
||||||
|
0,
|
||||||
|
0,
|
||||||
|
0
|
||||||
|
],
|
||||||
|
"qr_value": "",
|
||||||
|
"model_id": ""
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "m_0_6",
|
||||||
|
"row": 0,
|
||||||
|
"col": 6,
|
||||||
|
"front": {
|
||||||
|
"coords": [
|
||||||
|
0,
|
||||||
|
0,
|
||||||
|
0
|
||||||
|
],
|
||||||
|
"poses": []
|
||||||
|
},
|
||||||
|
"back": {
|
||||||
|
"coords": [
|
||||||
|
0,
|
||||||
|
0,
|
||||||
|
0
|
||||||
|
],
|
||||||
|
"poses": []
|
||||||
|
},
|
||||||
|
"qr": {
|
||||||
|
"coords": [
|
||||||
|
0,
|
||||||
|
0,
|
||||||
|
0
|
||||||
|
],
|
||||||
|
"qr_value": "",
|
||||||
|
"model_id": ""
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "m_1_6",
|
||||||
|
"row": 1,
|
||||||
|
"col": 6,
|
||||||
|
"front": {
|
||||||
|
"coords": [
|
||||||
|
0,
|
||||||
|
0,
|
||||||
|
0
|
||||||
|
],
|
||||||
|
"poses": []
|
||||||
|
},
|
||||||
|
"back": {
|
||||||
|
"coords": [
|
||||||
|
0,
|
||||||
|
0,
|
||||||
|
0
|
||||||
|
],
|
||||||
|
"poses": []
|
||||||
|
},
|
||||||
|
"qr": {
|
||||||
|
"coords": [
|
||||||
|
0,
|
||||||
|
0,
|
||||||
|
0
|
||||||
|
],
|
||||||
|
"qr_value": "",
|
||||||
|
"model_id": ""
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "m_0_3",
|
||||||
|
"row": 0,
|
||||||
|
"col": 3,
|
||||||
|
"front": {
|
||||||
|
"coords": [
|
||||||
|
0,
|
||||||
|
0,
|
||||||
|
0
|
||||||
|
],
|
||||||
|
"poses": []
|
||||||
|
},
|
||||||
|
"back": {
|
||||||
|
"coords": [
|
||||||
|
0,
|
||||||
|
0,
|
||||||
|
0
|
||||||
|
],
|
||||||
|
"poses": []
|
||||||
|
},
|
||||||
|
"qr": {
|
||||||
|
"coords": [
|
||||||
|
0,
|
||||||
|
0,
|
||||||
|
0
|
||||||
|
],
|
||||||
|
"qr_value": "",
|
||||||
|
"model_id": ""
|
||||||
|
}
|
||||||
|
},
|
||||||
{
|
{
|
||||||
"id": "m_1_3",
|
"id": "m_1_3",
|
||||||
"row": 1,
|
"row": 1,
|
||||||
@@ -537,8 +513,98 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"id": "m_1_4",
|
"id": "m_2_0",
|
||||||
"row": 1,
|
"row": 2,
|
||||||
|
"col": 0,
|
||||||
|
"front": {
|
||||||
|
"coords": [
|
||||||
|
0,
|
||||||
|
0,
|
||||||
|
0
|
||||||
|
],
|
||||||
|
"poses": []
|
||||||
|
},
|
||||||
|
"back": {
|
||||||
|
"coords": [
|
||||||
|
0,
|
||||||
|
0,
|
||||||
|
0
|
||||||
|
],
|
||||||
|
"poses": []
|
||||||
|
},
|
||||||
|
"qr": {
|
||||||
|
"coords": [
|
||||||
|
0,
|
||||||
|
0,
|
||||||
|
0
|
||||||
|
],
|
||||||
|
"qr_value": "",
|
||||||
|
"model_id": ""
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "m_2_1",
|
||||||
|
"row": 2,
|
||||||
|
"col": 1,
|
||||||
|
"front": {
|
||||||
|
"coords": [
|
||||||
|
0,
|
||||||
|
0,
|
||||||
|
0
|
||||||
|
],
|
||||||
|
"poses": []
|
||||||
|
},
|
||||||
|
"back": {
|
||||||
|
"coords": [
|
||||||
|
0,
|
||||||
|
0,
|
||||||
|
0
|
||||||
|
],
|
||||||
|
"poses": []
|
||||||
|
},
|
||||||
|
"qr": {
|
||||||
|
"coords": [
|
||||||
|
0,
|
||||||
|
0,
|
||||||
|
0
|
||||||
|
],
|
||||||
|
"qr_value": "",
|
||||||
|
"model_id": ""
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "m_0_1",
|
||||||
|
"row": 0,
|
||||||
|
"col": 1,
|
||||||
|
"front": {
|
||||||
|
"coords": [
|
||||||
|
0,
|
||||||
|
0,
|
||||||
|
0
|
||||||
|
],
|
||||||
|
"poses": []
|
||||||
|
},
|
||||||
|
"back": {
|
||||||
|
"coords": [
|
||||||
|
0,
|
||||||
|
0,
|
||||||
|
0
|
||||||
|
],
|
||||||
|
"poses": []
|
||||||
|
},
|
||||||
|
"qr": {
|
||||||
|
"coords": [
|
||||||
|
0,
|
||||||
|
0,
|
||||||
|
0
|
||||||
|
],
|
||||||
|
"qr_value": "",
|
||||||
|
"model_id": ""
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "m_0_4",
|
||||||
|
"row": 0,
|
||||||
"col": 4,
|
"col": 4,
|
||||||
"front": {
|
"front": {
|
||||||
"coords": [
|
"coords": [
|
||||||
|
|||||||
+360
-140
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"rows": 2,
|
"rows": 3,
|
||||||
"cols": 5,
|
"cols": 7,
|
||||||
"grid": [],
|
"grid": [],
|
||||||
"positions": [
|
"positions": [
|
||||||
{
|
{
|
||||||
@@ -8,9 +8,9 @@
|
|||||||
"col": 0,
|
"col": 0,
|
||||||
"side": "front",
|
"side": "front",
|
||||||
"coords": [
|
"coords": [
|
||||||
0.5391402360519819,
|
0.726789462066099,
|
||||||
-1.3221212932804989,
|
0.38329959318460166,
|
||||||
-0.04968159116162075
|
0.023383889548769105
|
||||||
],
|
],
|
||||||
"poses": []
|
"poses": []
|
||||||
},
|
},
|
||||||
@@ -19,9 +19,9 @@
|
|||||||
"col": 1,
|
"col": 1,
|
||||||
"side": "front",
|
"side": "front",
|
||||||
"coords": [
|
"coords": [
|
||||||
1.1801154538454173,
|
1.5997282224959652,
|
||||||
-1.3641834306281595,
|
0.3969445083875386,
|
||||||
-0.0384636372066124
|
0.021611522117307245
|
||||||
],
|
],
|
||||||
"poses": []
|
"poses": []
|
||||||
},
|
},
|
||||||
@@ -30,9 +30,9 @@
|
|||||||
"col": 1,
|
"col": 1,
|
||||||
"side": "back",
|
"side": "back",
|
||||||
"coords": [
|
"coords": [
|
||||||
1.3273254588744863,
|
1.5365709266996668,
|
||||||
-3.5287940020200854,
|
-1.5897806027940793,
|
||||||
-3.11993523836094
|
-3.138003565212702
|
||||||
],
|
],
|
||||||
"poses": []
|
"poses": []
|
||||||
},
|
},
|
||||||
@@ -41,108 +41,9 @@
|
|||||||
"col": 0,
|
"col": 0,
|
||||||
"side": "back",
|
"side": "back",
|
||||||
"coords": [
|
"coords": [
|
||||||
0.6499623251724095,
|
0.7250933954217442,
|
||||||
-3.634895898964233,
|
-1.585626974855502,
|
||||||
-3.06371982741706
|
3.1407193246894285
|
||||||
],
|
|
||||||
"poses": []
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"row": 0,
|
|
||||||
"col": 2,
|
|
||||||
"side": "front",
|
|
||||||
"coords": [
|
|
||||||
1.9780660285152205,
|
|
||||||
-1.4118225222055494,
|
|
||||||
-0.03933461738640764
|
|
||||||
],
|
|
||||||
"poses": []
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"row": 0,
|
|
||||||
"col": 3,
|
|
||||||
"side": "front",
|
|
||||||
"coords": [
|
|
||||||
2.783104887196572,
|
|
||||||
-1.4531680360293173,
|
|
||||||
-0.005407493209801511
|
|
||||||
],
|
|
||||||
"poses": []
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"row": 0,
|
|
||||||
"col": 4,
|
|
||||||
"side": "front",
|
|
||||||
"coords": [
|
|
||||||
3.4135017183966694,
|
|
||||||
-1.463517938299615,
|
|
||||||
-0.0022379727318056074
|
|
||||||
],
|
|
||||||
"poses": []
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"row": 1,
|
|
||||||
"col": 4,
|
|
||||||
"side": "back",
|
|
||||||
"coords": [
|
|
||||||
3.595502564320599,
|
|
||||||
-3.5861571623928663,
|
|
||||||
3.105599537556842
|
|
||||||
],
|
|
||||||
"poses": []
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"row": 1,
|
|
||||||
"col": 4,
|
|
||||||
"side": "front",
|
|
||||||
"coords": [
|
|
||||||
3.595502564320599,
|
|
||||||
-3.5861571623928663,
|
|
||||||
3.105599537556842
|
|
||||||
],
|
|
||||||
"poses": []
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"row": 1,
|
|
||||||
"col": 3,
|
|
||||||
"side": "back",
|
|
||||||
"coords": [
|
|
||||||
2.8436692518324937,
|
|
||||||
-3.5087893361886504,
|
|
||||||
-3.0640151322957476
|
|
||||||
],
|
|
||||||
"poses": []
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"row": 1,
|
|
||||||
"col": 3,
|
|
||||||
"side": "front",
|
|
||||||
"coords": [
|
|
||||||
2.8436692518324937,
|
|
||||||
-3.5087893361886504,
|
|
||||||
-3.0640151322957476
|
|
||||||
],
|
|
||||||
"poses": []
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"row": 1,
|
|
||||||
"col": 2,
|
|
||||||
"side": "back",
|
|
||||||
"coords": [
|
|
||||||
2.0238357078548397,
|
|
||||||
-3.519588818855445,
|
|
||||||
-3.0949553553741684
|
|
||||||
],
|
|
||||||
"poses": []
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"row": 1,
|
|
||||||
"col": 2,
|
|
||||||
"side": "front",
|
|
||||||
"coords": [
|
|
||||||
2.0238357078548397,
|
|
||||||
-3.519588818855445,
|
|
||||||
-3.0949553553741684
|
|
||||||
],
|
],
|
||||||
"poses": []
|
"poses": []
|
||||||
},
|
},
|
||||||
@@ -151,9 +52,9 @@
|
|||||||
"col": 1,
|
"col": 1,
|
||||||
"side": "front",
|
"side": "front",
|
||||||
"coords": [
|
"coords": [
|
||||||
1.3273254588744863,
|
1.5365709266996668,
|
||||||
-3.5287940020200854,
|
-1.5897806027940793,
|
||||||
-3.11993523836094
|
-3.138003565212702
|
||||||
],
|
],
|
||||||
"poses": []
|
"poses": []
|
||||||
},
|
},
|
||||||
@@ -162,9 +63,9 @@
|
|||||||
"col": 0,
|
"col": 0,
|
||||||
"side": "front",
|
"side": "front",
|
||||||
"coords": [
|
"coords": [
|
||||||
0.6499623251724095,
|
0.7250933954217442,
|
||||||
-3.634895898964233,
|
-1.585626974855502,
|
||||||
-3.06371982741706
|
3.1407193246894285
|
||||||
],
|
],
|
||||||
"poses": []
|
"poses": []
|
||||||
},
|
},
|
||||||
@@ -173,9 +74,9 @@
|
|||||||
"col": 0,
|
"col": 0,
|
||||||
"side": "back",
|
"side": "back",
|
||||||
"coords": [
|
"coords": [
|
||||||
0.39025594509020667,
|
0.695891857743624,
|
||||||
-5.593393651151741,
|
-3.6093540626907155,
|
||||||
-0.09001000079607593
|
-0.01891669546737457
|
||||||
],
|
],
|
||||||
"poses": []
|
"poses": []
|
||||||
},
|
},
|
||||||
@@ -184,9 +85,196 @@
|
|||||||
"col": 1,
|
"col": 1,
|
||||||
"side": "back",
|
"side": "back",
|
||||||
"coords": [
|
"coords": [
|
||||||
0.9989880876134289,
|
1.7054640840200619,
|
||||||
-5.633047903271201,
|
-3.6172190671922944,
|
||||||
-0.08518177305398167
|
0.04050928996758301
|
||||||
|
],
|
||||||
|
"poses": []
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"row": 0,
|
||||||
|
"col": 2,
|
||||||
|
"side": "front",
|
||||||
|
"coords": [
|
||||||
|
2.569630979384912,
|
||||||
|
0.39887714413011477,
|
||||||
|
0.013956327040945097
|
||||||
|
],
|
||||||
|
"poses": []
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"row": 0,
|
||||||
|
"col": 3,
|
||||||
|
"side": "front",
|
||||||
|
"coords": [
|
||||||
|
3.5131599402572933,
|
||||||
|
0.3616416504697895,
|
||||||
|
0.0177534721641621
|
||||||
|
],
|
||||||
|
"poses": []
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"row": 0,
|
||||||
|
"col": 4,
|
||||||
|
"side": "front",
|
||||||
|
"coords": [
|
||||||
|
4.385626456624971,
|
||||||
|
0.37358124345384475,
|
||||||
|
0.014464186351950986
|
||||||
|
],
|
||||||
|
"poses": []
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"row": 0,
|
||||||
|
"col": 5,
|
||||||
|
"side": "front",
|
||||||
|
"coords": [
|
||||||
|
5.286651848873122,
|
||||||
|
0.36270375923170595,
|
||||||
|
-0.02478120105661721
|
||||||
|
],
|
||||||
|
"poses": []
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"row": 0,
|
||||||
|
"col": 6,
|
||||||
|
"side": "front",
|
||||||
|
"coords": [
|
||||||
|
6.301663107708812,
|
||||||
|
0.35009837193686855,
|
||||||
|
0.05028809910702322
|
||||||
|
],
|
||||||
|
"poses": []
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"row": 1,
|
||||||
|
"col": 6,
|
||||||
|
"side": "back",
|
||||||
|
"coords": [
|
||||||
|
6.302211902696101,
|
||||||
|
-1.6741865723108142,
|
||||||
|
-3.141294889035836
|
||||||
|
],
|
||||||
|
"poses": []
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"row": 1,
|
||||||
|
"col": 6,
|
||||||
|
"side": "front",
|
||||||
|
"coords": [
|
||||||
|
6.302211902696101,
|
||||||
|
-1.6741865723108142,
|
||||||
|
-3.141294889035836
|
||||||
|
],
|
||||||
|
"poses": []
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"row": 1,
|
||||||
|
"col": 5,
|
||||||
|
"side": "back",
|
||||||
|
"coords": [
|
||||||
|
5.385423949389957,
|
||||||
|
-1.6569851054137088,
|
||||||
|
3.114584306608913
|
||||||
|
],
|
||||||
|
"poses": []
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"row": 1,
|
||||||
|
"col": 5,
|
||||||
|
"side": "front",
|
||||||
|
"coords": [
|
||||||
|
5.385423949389957,
|
||||||
|
-1.6569851054137088,
|
||||||
|
3.114584306608913
|
||||||
|
],
|
||||||
|
"poses": []
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"row": 1,
|
||||||
|
"col": 4,
|
||||||
|
"side": "back",
|
||||||
|
"coords": [
|
||||||
|
4.520056315813734,
|
||||||
|
-1.6370730446353792,
|
||||||
|
3.12134578550089
|
||||||
|
],
|
||||||
|
"poses": []
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"row": 1,
|
||||||
|
"col": 4,
|
||||||
|
"side": "front",
|
||||||
|
"coords": [
|
||||||
|
4.520056315813734,
|
||||||
|
-1.6370730446353792,
|
||||||
|
3.12134578550089
|
||||||
|
],
|
||||||
|
"poses": []
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"row": 1,
|
||||||
|
"col": 3,
|
||||||
|
"side": "back",
|
||||||
|
"coords": [
|
||||||
|
3.600184234659078,
|
||||||
|
-1.6299160740114962,
|
||||||
|
-3.127182700330921
|
||||||
|
],
|
||||||
|
"poses": []
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"row": 1,
|
||||||
|
"col": 3,
|
||||||
|
"side": "front",
|
||||||
|
"coords": [
|
||||||
|
3.600184234659078,
|
||||||
|
-1.6299160740114962,
|
||||||
|
-3.127182700330921
|
||||||
|
],
|
||||||
|
"poses": []
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"row": 1,
|
||||||
|
"col": 2,
|
||||||
|
"side": "back",
|
||||||
|
"coords": [
|
||||||
|
2.5627723519921295,
|
||||||
|
-1.606403204776104,
|
||||||
|
3.1392697865149666
|
||||||
|
],
|
||||||
|
"poses": []
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"row": 1,
|
||||||
|
"col": 2,
|
||||||
|
"side": "front",
|
||||||
|
"coords": [
|
||||||
|
2.5627723519921295,
|
||||||
|
-1.606403204776104,
|
||||||
|
3.1392697865149666
|
||||||
|
],
|
||||||
|
"poses": []
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"row": 2,
|
||||||
|
"col": 0,
|
||||||
|
"side": "front",
|
||||||
|
"coords": [
|
||||||
|
0.695891857743624,
|
||||||
|
-3.6093540626907155,
|
||||||
|
-0.01891669546737457
|
||||||
|
],
|
||||||
|
"poses": []
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"row": 2,
|
||||||
|
"col": 1,
|
||||||
|
"side": "front",
|
||||||
|
"coords": [
|
||||||
|
1.7054640840200619,
|
||||||
|
-3.6172190671922944,
|
||||||
|
0.04050928996758301
|
||||||
],
|
],
|
||||||
"poses": []
|
"poses": []
|
||||||
},
|
},
|
||||||
@@ -195,9 +283,20 @@
|
|||||||
"col": 2,
|
"col": 2,
|
||||||
"side": "back",
|
"side": "back",
|
||||||
"coords": [
|
"coords": [
|
||||||
1.7493440267548326,
|
2.49138966620064,
|
||||||
-5.7036971258959746,
|
-3.6258193640543435,
|
||||||
-0.10505541857885684
|
0.011734020269766346
|
||||||
|
],
|
||||||
|
"poses": []
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"row": 2,
|
||||||
|
"col": 2,
|
||||||
|
"side": "front",
|
||||||
|
"coords": [
|
||||||
|
2.49138966620064,
|
||||||
|
-3.6258193640543435,
|
||||||
|
0.011734020269766346
|
||||||
],
|
],
|
||||||
"poses": []
|
"poses": []
|
||||||
},
|
},
|
||||||
@@ -206,9 +305,20 @@
|
|||||||
"col": 3,
|
"col": 3,
|
||||||
"side": "back",
|
"side": "back",
|
||||||
"coords": [
|
"coords": [
|
||||||
2.407336669431407,
|
3.384299605055195,
|
||||||
-5.76958512207572,
|
-3.633059580331103,
|
||||||
-0.10251877401062247
|
0.00825489611393585
|
||||||
|
],
|
||||||
|
"poses": []
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"row": 2,
|
||||||
|
"col": 3,
|
||||||
|
"side": "front",
|
||||||
|
"coords": [
|
||||||
|
3.384299605055195,
|
||||||
|
-3.633059580331103,
|
||||||
|
0.00825489611393585
|
||||||
],
|
],
|
||||||
"poses": []
|
"poses": []
|
||||||
},
|
},
|
||||||
@@ -217,19 +327,129 @@
|
|||||||
"col": 4,
|
"col": 4,
|
||||||
"side": "back",
|
"side": "back",
|
||||||
"coords": [
|
"coords": [
|
||||||
3.132062476985721,
|
4.499614777619532,
|
||||||
-5.850166571217611,
|
-3.6568143703418405,
|
||||||
-0.09127007182804729
|
0.006128126167414207
|
||||||
|
],
|
||||||
|
"poses": []
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"row": 2,
|
||||||
|
"col": 4,
|
||||||
|
"side": "front",
|
||||||
|
"coords": [
|
||||||
|
4.499614777619532,
|
||||||
|
-3.6568143703418405,
|
||||||
|
0.006128126167414207
|
||||||
|
],
|
||||||
|
"poses": []
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"row": 2,
|
||||||
|
"col": 5,
|
||||||
|
"side": "back",
|
||||||
|
"coords": [
|
||||||
|
5.358194832629194,
|
||||||
|
-3.6555884207351923,
|
||||||
|
0.00220732555627522
|
||||||
|
],
|
||||||
|
"poses": []
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"row": 2,
|
||||||
|
"col": 5,
|
||||||
|
"side": "front",
|
||||||
|
"coords": [
|
||||||
|
5.358194832629194,
|
||||||
|
-3.6555884207351923,
|
||||||
|
0.00220732555627522
|
||||||
|
],
|
||||||
|
"poses": []
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"row": 2,
|
||||||
|
"col": 6,
|
||||||
|
"side": "back",
|
||||||
|
"coords": [
|
||||||
|
6.186931240051006,
|
||||||
|
-3.660069561737178,
|
||||||
|
-0.004740651362042846
|
||||||
|
],
|
||||||
|
"poses": []
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"row": 2,
|
||||||
|
"col": 6,
|
||||||
|
"side": "front",
|
||||||
|
"coords": [
|
||||||
|
6.186931240051006,
|
||||||
|
-3.660069561737178,
|
||||||
|
-0.004740651362042846
|
||||||
|
],
|
||||||
|
"poses": []
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"row": 3,
|
||||||
|
"col": 4,
|
||||||
|
"side": "back",
|
||||||
|
"coords": [
|
||||||
|
4.437106056578727,
|
||||||
|
-5.675385129487837,
|
||||||
|
3.113166940596223
|
||||||
|
],
|
||||||
|
"poses": []
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"row": 3,
|
||||||
|
"col": 3,
|
||||||
|
"side": "back",
|
||||||
|
"coords": [
|
||||||
|
3.4577008440661285,
|
||||||
|
-5.703184532839961,
|
||||||
|
-3.109685273113602
|
||||||
|
],
|
||||||
|
"poses": []
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"row": 3,
|
||||||
|
"col": 2,
|
||||||
|
"side": "back",
|
||||||
|
"coords": [
|
||||||
|
2.3341924779097645,
|
||||||
|
-5.623728684341702,
|
||||||
|
-3.095440697434253
|
||||||
|
],
|
||||||
|
"poses": []
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"row": 3,
|
||||||
|
"col": 1,
|
||||||
|
"side": "back",
|
||||||
|
"coords": [
|
||||||
|
1.6126711501886855,
|
||||||
|
-5.64506932868408,
|
||||||
|
-3.0994397969930265
|
||||||
|
],
|
||||||
|
"poses": []
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"row": 3,
|
||||||
|
"col": 0,
|
||||||
|
"side": "back",
|
||||||
|
"coords": [
|
||||||
|
0.6321940488248773,
|
||||||
|
-5.633649464598426,
|
||||||
|
3.129000825841382
|
||||||
],
|
],
|
||||||
"poses": []
|
"poses": []
|
||||||
}
|
}
|
||||||
],
|
],
|
||||||
"arm_initial_pose": [
|
"arm_initial_pose": [
|
||||||
-90.333323,
|
-89.999795,
|
||||||
-90.079952,
|
-89.999946,
|
||||||
0.160037,
|
-0.000131,
|
||||||
-90.571318,
|
-90.00001,
|
||||||
0.093654,
|
4.1e-05,
|
||||||
22.232898
|
0.000116
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
@@ -9,12 +9,12 @@
|
|||||||
"name": "front_1",
|
"name": "front_1",
|
||||||
"photo_type": "front",
|
"photo_type": "front",
|
||||||
"arm_angles": [
|
"arm_angles": [
|
||||||
-93.586541,
|
-81.796775,
|
||||||
-184.343305,
|
-85.406752,
|
||||||
50.583239,
|
-5.803223,
|
||||||
-38.326674,
|
-109.799747,
|
||||||
-85.153333,
|
91.66639,
|
||||||
20.399989
|
2.446712
|
||||||
],
|
],
|
||||||
"speed": 500,
|
"speed": 500,
|
||||||
"description": ""
|
"description": ""
|
||||||
@@ -24,12 +24,12 @@
|
|||||||
"name": "back_1",
|
"name": "back_1",
|
||||||
"photo_type": "back",
|
"photo_type": "back",
|
||||||
"arm_angles": [
|
"arm_angles": [
|
||||||
15.860045,
|
-81.796823,
|
||||||
-161.133416,
|
-85.406717,
|
||||||
137.999016,
|
-5.803284,
|
||||||
-161.996719,
|
-109.799953,
|
||||||
168.000989,
|
91.666459,
|
||||||
15.653445
|
2.44676
|
||||||
],
|
],
|
||||||
"speed": 500,
|
"speed": 500,
|
||||||
"description": ""
|
"description": ""
|
||||||
|
|||||||
@@ -0,0 +1,44 @@
|
|||||||
|
[
|
||||||
|
{
|
||||||
|
"id": "qr_1779278140334",
|
||||||
|
"name": "二维码1",
|
||||||
|
"joint_angles": [
|
||||||
|
89.999979,
|
||||||
|
-0.000118,
|
||||||
|
-120.09949,
|
||||||
|
-30,
|
||||||
|
-105,
|
||||||
|
0
|
||||||
|
],
|
||||||
|
"qr_value": "BG042110276",
|
||||||
|
"model_id": ""
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "qr_1779286233426",
|
||||||
|
"name": "左侧二维码",
|
||||||
|
"joint_angles": [
|
||||||
|
-70.967019,
|
||||||
|
-19.319962,
|
||||||
|
-67.929797,
|
||||||
|
-90.749908,
|
||||||
|
-121.735483,
|
||||||
|
20.399961
|
||||||
|
],
|
||||||
|
"qr_value": "",
|
||||||
|
"model_id": ""
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "qr_1779954274845",
|
||||||
|
"name": "右侧二维码",
|
||||||
|
"joint_angles": [
|
||||||
|
-106.216678,
|
||||||
|
35.346758,
|
||||||
|
-134.01322,
|
||||||
|
-79.250251,
|
||||||
|
-84.069984,
|
||||||
|
21.982971
|
||||||
|
],
|
||||||
|
"qr_value": "",
|
||||||
|
"model_id": ""
|
||||||
|
}
|
||||||
|
]
|
||||||
@@ -1,7 +0,0 @@
|
|||||||
flask>=2.0
|
|
||||||
flask-cors>=3.0
|
|
||||||
pymycobot>=4.0.0
|
|
||||||
opencv-python>=4.5
|
|
||||||
pyzbar>=0.1.8
|
|
||||||
requests>=2.25
|
|
||||||
numpy>=1.20
|
|
||||||
@@ -1,67 +0,0 @@
|
|||||||
#!/usr/bin/env python3
|
|
||||||
"""修复激光雷达时间戳偏移的修正器 v5"""
|
|
||||||
import os, sys, rclpy
|
|
||||||
from rclpy.node import Node
|
|
||||||
from sensor_msgs.msg import LaserScan
|
|
||||||
from builtin_interfaces.msg import Time
|
|
||||||
|
|
||||||
LOCKFILE = "/tmp/scan_fixer.lock"
|
|
||||||
|
|
||||||
if os.path.exists(LOCKFILE):
|
|
||||||
with open(LOCKFILE) as f:
|
|
||||||
old_pid = int(f.read().strip())
|
|
||||||
try:
|
|
||||||
os.kill(old_pid, 0)
|
|
||||||
print(f"Another fixer running PID {old_pid}, exit.", file=sys.stderr)
|
|
||||||
sys.exit(1)
|
|
||||||
except (OSError, ProcessLookupError):
|
|
||||||
print(f"Stale lock removed (PID {old_pid} dead)", file=sys.stderr)
|
|
||||||
|
|
||||||
with open(LOCKFILE, "w") as f:
|
|
||||||
f.write(str(os.getpid()))
|
|
||||||
|
|
||||||
def main():
|
|
||||||
rclpy.init(args=sys.argv[1:])
|
|
||||||
node = Node('scan_timestamp_fixer')
|
|
||||||
offset = 2.0
|
|
||||||
pub = node.create_publisher(LaserScan, '/scan_corrected', 10)
|
|
||||||
count = [0]
|
|
||||||
|
|
||||||
def cb(msg: LaserScan):
|
|
||||||
count[0] += 1
|
|
||||||
s, ns = msg.header.stamp.sec, msg.header.stamp.nanosec
|
|
||||||
s2 = s - int(offset)
|
|
||||||
ns2 = ns - int((offset % 1) * 1e9)
|
|
||||||
if ns2 < 0:
|
|
||||||
ns2 += 1000000000
|
|
||||||
s2 -= 1
|
|
||||||
out = LaserScan()
|
|
||||||
out.header.frame_id = msg.header.frame_id
|
|
||||||
out.header.stamp = Time(sec=s2, nanosec=ns2)
|
|
||||||
out.angle_min = msg.angle_min
|
|
||||||
out.angle_max = msg.angle_max
|
|
||||||
out.angle_increment = msg.angle_increment
|
|
||||||
out.time_increment = msg.time_increment
|
|
||||||
out.scan_time = msg.scan_time
|
|
||||||
out.range_min = msg.range_min
|
|
||||||
out.range_max = msg.range_max
|
|
||||||
out.ranges = msg.ranges
|
|
||||||
out.intensities = msg.intensities
|
|
||||||
pub.publish(out)
|
|
||||||
if count[0] % 200 == 0:
|
|
||||||
node.get_logger().info(f'#{count[0]} /scan={s} -> /scan_corrected={s2}')
|
|
||||||
|
|
||||||
node.create_subscription(LaserScan, '/scan', cb, 10)
|
|
||||||
node.get_logger().info(f'Fixer PID={os.getpid()}, offset={offset}s')
|
|
||||||
|
|
||||||
try:
|
|
||||||
while rclpy.ok():
|
|
||||||
rclpy.spin_once(node, timeout_sec=0.5)
|
|
||||||
finally:
|
|
||||||
node.destroy_node()
|
|
||||||
rclpy.shutdown()
|
|
||||||
if os.path.exists(LOCKFILE):
|
|
||||||
os.unlink(LOCKFILE)
|
|
||||||
|
|
||||||
if __name__ == '__main__':
|
|
||||||
main()
|
|
||||||
@@ -1,17 +0,0 @@
|
|||||||
#!/bin/bash
|
|
||||||
source /opt/ros/humble/setup.bash
|
|
||||||
source /home/elephant/agv_pro_ros2/install/setup.bash
|
|
||||||
export ROS_DOMAIN_ID=1
|
|
||||||
cd /home/elephant/agv_pro_ros2
|
|
||||||
nohup ros2 daemon start >/dev/null 2>&1 &
|
|
||||||
sleep 5
|
|
||||||
nohup ros2 launch agv_pro_bringup agv_pro_bringup.launch.py port_name:=/dev/agvpro_controller > /tmp/ros2_bringup.log 2>&1 &
|
|
||||||
sleep 8
|
|
||||||
nohup python3 /home/elephant/work/scan_fixer/fix_scan_timestamp.py > /tmp/scan_fixer.log 2>&1 &
|
|
||||||
sleep 5
|
|
||||||
nohup ros2 launch agv_pro_navigation2 navigation2_active.launch.py autostart:=True > /tmp/ros2_nav2.log 2>&1 &
|
|
||||||
sleep 15
|
|
||||||
cd /home/elephant/work/agv_app && nohup python3 app.py > /tmp/agv_flask.log 2>&1 &
|
|
||||||
sleep 5
|
|
||||||
echo "ALL_STARTED"
|
|
||||||
ps aux | grep -E 'lslidar|agv_pro_node|nav2_container|scan_timestamp_fixer|ros2-daemon|app.py' | grep -v grep
|
|
||||||
@@ -1,5 +0,0 @@
|
|||||||
#!/bin/bash
|
|
||||||
# 启动 AGV 拍摄系统
|
|
||||||
|
|
||||||
cd ~/work/agv_app
|
|
||||||
python3 app.py
|
|
||||||
@@ -1,17 +0,0 @@
|
|||||||
#!/bin/bash
|
|
||||||
source /opt/ros/humble/setup.bash
|
|
||||||
source /home/elephant/agv_pro_ros2/install/setup.bash
|
|
||||||
export ROS_DOMAIN_ID=1
|
|
||||||
cd /home/elephant/agv_pro_ros2
|
|
||||||
nohup ros2 daemon start >/dev/null 2>&1 &
|
|
||||||
sleep 5
|
|
||||||
nohup ros2 launch agv_pro_bringup agv_pro_bringup.launch.py port_name:=/dev/agvpro_controller > /tmp/ros2_bringup.log 2>&1 &
|
|
||||||
sleep 8
|
|
||||||
nohup python3 /home/elephant/work/scan_fixer/fix_scan_timestamp.py > /tmp/scan_fixer.log 2>&1 &
|
|
||||||
sleep 5
|
|
||||||
nohup ros2 launch agv_pro_navigation2 navigation2_active.launch.py autostart:=True > /tmp/ros2_nav2.log 2>&1 &
|
|
||||||
sleep 15
|
|
||||||
cd /home/elephant/work/agv_app && nohup python3 app.py > /tmp/agv_flask.log 2>&1 &
|
|
||||||
sleep 5
|
|
||||||
echo "ALL_STARTED"
|
|
||||||
ps aux | grep -E 'lslidar|agv_pro_node|nav2_container|scan_timestamp_fixer|ros2-daemon|app.py' | grep -v grep
|
|
||||||
@@ -1,338 +0,0 @@
|
|||||||
#!/bin/bash
|
|
||||||
# ============================================================
|
|
||||||
# Robot AGV 全量启动脚本 v4.0
|
|
||||||
# 修复:
|
|
||||||
# - v4.0: 彻底杀死 ros2 daemon 进程 + 启动前进程数量检查
|
|
||||||
# - v3.0: 彻底清理 FastRTPS 共享内存文件(永久修复 DDS 通信问题)
|
|
||||||
# - v2.7: 添加 ROS_DOMAIN_ID 环境变量传递
|
|
||||||
# - v2.6: 清理 scan_fixer lock 文件防残留
|
|
||||||
# ============================================================
|
|
||||||
set -e
|
|
||||||
|
|
||||||
AGV_APP_DIR="/home/elephant/work/agv_app"
|
|
||||||
AGV_ROS2_DIR="/home/elephant/agv_pro_ros2"
|
|
||||||
ROS_DOMAIN_ID_VAL=1
|
|
||||||
|
|
||||||
echo "=========================================="
|
|
||||||
echo " Robot AGV 全量启动 v4.0"
|
|
||||||
echo "=========================================="
|
|
||||||
echo ""
|
|
||||||
|
|
||||||
# ---------- 1. 清理旧进程 + FastRTPS 共享内存 ----------
|
|
||||||
echo "[1/8] 清理旧进程和共享内存..."
|
|
||||||
|
|
||||||
# 杀掉所有相关进程(先软杀,再硬杀确保干净)
|
|
||||||
pkill -f "ros2 launch agv_pro_bringup" 2>/dev/null || true
|
|
||||||
pkill -f "ros2 launch agv_pro_navigation2" 2>/dev/null || true
|
|
||||||
pkill -f "agv_pro_node" 2>/dev/null || true
|
|
||||||
pkill -f "lslidar_driver_node" 2>/dev/null || true
|
|
||||||
pkill -f "component_container" 2>/dev/null || true
|
|
||||||
pkill -f "robot_state_publisher" 2>/dev/null || true
|
|
||||||
pkill -f "fix_scan_timestamp" 2>/dev/null || true
|
|
||||||
pkill -f "clock_publisher" 2>/dev/null || true
|
|
||||||
pkill -f "python.*app.py" 2>/dev/null || true
|
|
||||||
sleep 2
|
|
||||||
|
|
||||||
# 【关键】硬杀确保干净
|
|
||||||
echo " 硬杀残留进程..."
|
|
||||||
pkill -9 -f "agv_pro_node" 2>/dev/null || true
|
|
||||||
pkill -9 -f "lslidar_driver_node" 2>/dev/null || true
|
|
||||||
pkill -9 -f "component_container" 2>/dev/null || true
|
|
||||||
pkill -9 -f "clock_publisher" 2>/dev/null || true
|
|
||||||
pkill -9 -f "fix_scan_timestamp" 2>/dev/null || true
|
|
||||||
pkill -9 -f "app.py" 2>/dev/null || true
|
|
||||||
sleep 1
|
|
||||||
|
|
||||||
# 【关键】杀死 ros2 daemon 进程本身(不是只 stop,而是杀进程)
|
|
||||||
echo " 重置 ros2 daemon..."
|
|
||||||
pkill -f "ros2-daemon" 2>/dev/null || true
|
|
||||||
pkill -9 -f "ros2-daemon" 2>/dev/null || true
|
|
||||||
sleep 2
|
|
||||||
|
|
||||||
# 【关键】清理 FastRTPS 共享内存文件(杀进程后立即清理)
|
|
||||||
echo " 清理 FastRTPS 共享内存文件..."
|
|
||||||
FASTRTPS_COUNT=$(ls /dev/shm/fastrtps_* 2>/dev/null | wc -l || echo 0)
|
|
||||||
if [ "$FASTRTPS_COUNT" -gt 0 ]; then
|
|
||||||
rm -rf /dev/shm/fastrtps_*
|
|
||||||
echo " 已清理 $FASTRTPS_COUNT 个 FastRTPS 文件"
|
|
||||||
else
|
|
||||||
echo " 无 FastRTPS 文件残留"
|
|
||||||
fi
|
|
||||||
|
|
||||||
# 清理 scan_fixer 锁文件
|
|
||||||
rm -f /tmp/scan_fixer.lock
|
|
||||||
|
|
||||||
# 【关键】验证进程已全部停止
|
|
||||||
echo " 验证进程停止..."
|
|
||||||
PROC_COUNT=$(ps aux | grep -E 'agv_pro_node|lslidar_driver_node|component_container|fix_scan_timestamp|app.py' | grep -v grep | wc -l || echo 0)
|
|
||||||
echo " 残留进程数: $PROC_COUNT"
|
|
||||||
if [ "$PROC_COUNT" -gt 0 ]; then
|
|
||||||
echo " ⚠️ 仍有进程残留,强制终止..."
|
|
||||||
pkill -9 -f "agv_pro_node" 2>/dev/null || true
|
|
||||||
pkill -9 -f "lslidar_driver_node" 2>/dev/null || true
|
|
||||||
pkill -9 -f "component_container" 2>/dev/null || true
|
|
||||||
pkill -9 -f "fix_scan_timestamp" 2>/dev/null || true
|
|
||||||
pkill -9 -f "app.py" 2>/dev/null || true
|
|
||||||
sleep 2
|
|
||||||
PROC_COUNT2=$(ps aux | grep -E 'agv_pro_node|lslidar_driver_node|component_container|fix_scan_timestamp|app.py' | grep -v grep | wc -l || echo 0)
|
|
||||||
echo " 清理后残留: $PROC_COUNT2"
|
|
||||||
fi
|
|
||||||
echo " ✅ 清理完成"
|
|
||||||
|
|
||||||
# ---------- 2. 启动 ros2 daemon ----------
|
|
||||||
echo "[2/8] 启动 ros2 daemon..."
|
|
||||||
source /opt/ros/humble/setup.bash 2>/dev/null || true
|
|
||||||
|
|
||||||
# 再次确保没有残留共享内存(启动 daemon 前)
|
|
||||||
rm -rf /dev/shm/fastrtps_* 2>/dev/null || true
|
|
||||||
|
|
||||||
# 使用 bash -c 确保环境变量正确传递
|
|
||||||
nohup bash -c "source /opt/ros/humble/setup.bash && export ROS_DOMAIN_ID=$ROS_DOMAIN_ID_VAL && ros2 daemon start" >/dev/null 2>&1 &
|
|
||||||
sleep 4
|
|
||||||
|
|
||||||
# 验证 daemon 是否就绪(用简单的 topic list 测试)
|
|
||||||
DAEMON_OK=0
|
|
||||||
for i in $(seq 1 5); do
|
|
||||||
DAEMON_TOPICS=$(source /opt/ros/humble/setup.bash && ROS_DOMAIN_ID=$ROS_DOMAIN_ID_VAL timeout 3 ros2 topic list 2>&1 | wc -l || echo 0)
|
|
||||||
if [ "$DAEMON_TOPICS" -gt 0 ]; then
|
|
||||||
DAEMON_OK=1
|
|
||||||
echo " ✅ ros2 daemon 就绪"
|
|
||||||
break
|
|
||||||
fi
|
|
||||||
sleep 2
|
|
||||||
done
|
|
||||||
if [ "$DAEMON_OK" -eq 0 ]; then
|
|
||||||
echo " ⚠️ ros2 daemon 可能有问题,继续尝试启动组件..."
|
|
||||||
fi
|
|
||||||
|
|
||||||
# ---------- 3. 启动 bringup (含激光雷达) ----------
|
|
||||||
echo "[3/8] 启动 AGV Bringup..."
|
|
||||||
source /opt/ros/humble/setup.bash 2>/dev/null || true
|
|
||||||
|
|
||||||
# 【关键】启动前最后确认没有残留共享内存
|
|
||||||
rm -rf /dev/shm/fastrtps_* 2>/dev/null || true
|
|
||||||
|
|
||||||
cd "$AGV_ROS2_DIR"
|
|
||||||
source install/setup.bash
|
|
||||||
|
|
||||||
nohup bash -c "export ROS_DOMAIN_ID=$ROS_DOMAIN_ID_VAL && ros2 launch agv_pro_bringup agv_pro_bringup.launch.py port_name:=/dev/agvpro_controller" > /tmp/ros2_bringup.log 2>&1 &
|
|
||||||
BRINGUP_PID=$!
|
|
||||||
echo " bringup PID: $BRINGUP_PID"
|
|
||||||
|
|
||||||
echo " 等待 bringup 就绪..."
|
|
||||||
BRINGUP_OK=0
|
|
||||||
for i in $(seq 1 20); do
|
|
||||||
if ROS_DOMAIN_ID=$ROS_DOMAIN_ID_VAL ros2 topic list 2>/dev/null | grep -q '/odom'; then
|
|
||||||
echo " ✅ bringup 已就绪 (${i}x2秒)"
|
|
||||||
BRINGUP_OK=1
|
|
||||||
break
|
|
||||||
fi
|
|
||||||
sleep 2
|
|
||||||
done
|
|
||||||
if [ "$BRINGUP_OK" -eq 0 ]; then
|
|
||||||
echo " ⚠️ bringup 未检测到 /odom,继续启动后续组件..."
|
|
||||||
tail -5 /tmp/ros2_bringup.log 2>/dev/null || true
|
|
||||||
fi
|
|
||||||
|
|
||||||
# ---------- 3.5 启动系统时钟发布器 ----------
|
|
||||||
echo "[3.5/8] 启动系统时钟发布器 (clock_publisher)..."
|
|
||||||
|
|
||||||
nohup bash -c "source /opt/ros/humble/setup.bash && \
|
|
||||||
ROS_DOMAIN_ID=$ROS_DOMAIN_ID_VAL python3 /home/elephant/work/scan_fixer/clock_publisher.py" \
|
|
||||||
> /tmp/clock_publisher.log 2>&1 &
|
|
||||||
CLOCK_PID=$!
|
|
||||||
echo " clock_publisher PID: $CLOCK_PID"
|
|
||||||
sleep 2
|
|
||||||
|
|
||||||
# 验证 /clock 话题
|
|
||||||
if ROS_DOMAIN_ID=$ROS_DOMAIN_ID_VAL ros2 topic list 2>/dev/null | grep -q '/clock'; then
|
|
||||||
echo " ✅ /clock 已上线"
|
|
||||||
else
|
|
||||||
echo " ⚠️ /clock 未上线,检查日志:"
|
|
||||||
tail -5 /tmp/clock_publisher.log 2>/dev/null || true
|
|
||||||
fi
|
|
||||||
|
|
||||||
# ---------- 4. 启动激光时间戳修正节点 ----------
|
|
||||||
echo "[4/8] 启动激光时间戳修正节点..."
|
|
||||||
|
|
||||||
# 确保 /scan 存在
|
|
||||||
SCAN_OK=0
|
|
||||||
for i in $(seq 1 10); do
|
|
||||||
if ROS_DOMAIN_ID=$ROS_DOMAIN_ID_VAL ros2 topic list 2>/dev/null | grep -q '/scan'; then
|
|
||||||
echo " /scan 话题已上线"
|
|
||||||
SCAN_OK=1
|
|
||||||
break
|
|
||||||
fi
|
|
||||||
sleep 2
|
|
||||||
done
|
|
||||||
if [ "$SCAN_OK" -eq 0 ]; then
|
|
||||||
echo " ⚠️ /scan 未上线,检查 bringup 日志"
|
|
||||||
fi
|
|
||||||
|
|
||||||
nohup bash -c "source /opt/ros/humble/setup.bash && \
|
|
||||||
ROS_DOMAIN_ID=$ROS_DOMAIN_ID_VAL python3 /home/elephant/work/scan_fixer/fix_scan_timestamp_v6.py" \
|
|
||||||
> /tmp/scan_fixer.log 2>&1 &
|
|
||||||
FIXER_PID=$!
|
|
||||||
echo " fix_scan_timestamp PID: $FIXER_PID"
|
|
||||||
sleep 5
|
|
||||||
|
|
||||||
# 验证 fixer 进程和 scan_corrected
|
|
||||||
FIXER_COUNT=$(ps aux | grep -c "[f]ix_scan_timestamp" 2>/dev/null || echo 0)
|
|
||||||
if [ "$FIXER_COUNT" -gt 1 ]; then
|
|
||||||
echo " ⚠️ 发现 $FIXER_COUNT 个 fixer 进程,杀掉多余的..."
|
|
||||||
pkill -f "fix_scan_timestamp" 2>/dev/null || true
|
|
||||||
pkill -f "clock_publisher" 2>/dev/null || true
|
|
||||||
sleep 2
|
|
||||||
rm -f /tmp/scan_fixer.lock
|
|
||||||
nohup bash -c "source /opt/ros/humble/setup.bash && \
|
|
||||||
ROS_DOMAIN_ID=$ROS_DOMAIN_ID_VAL python3 /home/elephant/work/scan_fixer/fix_scan_timestamp_v6.py" \
|
|
||||||
> /tmp/scan_fixer.log 2>&1 &
|
|
||||||
FIXER_PID=$!
|
|
||||||
sleep 3
|
|
||||||
fi
|
|
||||||
|
|
||||||
if ROS_DOMAIN_ID=$ROS_DOMAIN_ID_VAL ros2 topic list 2>/dev/null | grep -q '/scan_corrected'; then
|
|
||||||
echo " ✅ /scan_corrected 已上线"
|
|
||||||
else
|
|
||||||
echo " ⚠️ /scan_corrected 未上线,检查日志:"
|
|
||||||
tail -5 /tmp/scan_fixer.log 2>/dev/null || true
|
|
||||||
fi
|
|
||||||
|
|
||||||
# ---------- 5. 启动 Nav2 ----------
|
|
||||||
echo "[5/8] 启动 Nav2 导航..."
|
|
||||||
source /opt/ros/humble/setup.bash 2>/dev/null || true
|
|
||||||
cd "$AGV_ROS2_DIR"
|
|
||||||
source install/setup.bash
|
|
||||||
|
|
||||||
nohup bash -c "source /opt/ros/humble/setup.bash && \
|
|
||||||
source /home/elephant/agv_pro_ros2/install/setup.bash && \
|
|
||||||
export ROS_DOMAIN_ID=$ROS_DOMAIN_ID_VAL && \
|
|
||||||
ros2 launch agv_pro_navigation2 navigation2_active.launch.py \
|
|
||||||
autostart:=True" > /tmp/ros2_nav2.log 2>&1 &
|
|
||||||
NAV2_PID=$!
|
|
||||||
echo " Nav2 PID: $NAV2_PID"
|
|
||||||
sleep 12
|
|
||||||
|
|
||||||
echo " 等待 Nav2 节点就绪..."
|
|
||||||
NAV2_OK=0
|
|
||||||
for i in $(seq 1 15); do
|
|
||||||
NODES=$(ROS_DOMAIN_ID=$ROS_DOMAIN_ID_VAL ros2 node list 2>/dev/null | \
|
|
||||||
grep -cE 'lifecycle_manager_navigation|bt_navigator|controller_server' 2>/dev/null || echo 0)
|
|
||||||
NODES=$(echo "$NODES" | tr -d '\n' | awk '{print $1}')
|
|
||||||
if [ "$NODES" -ge 3 ] 2>/dev/null; then
|
|
||||||
echo " ✅ Nav2 节点已就绪 ($NODES 个)"
|
|
||||||
NAV2_OK=1
|
|
||||||
break
|
|
||||||
fi
|
|
||||||
sleep 3
|
|
||||||
done
|
|
||||||
if [ "$NAV2_OK" -eq 0 ]; then
|
|
||||||
echo " ⚠️ Nav2 节点未完全就绪,继续..."
|
|
||||||
fi
|
|
||||||
|
|
||||||
# ---------- 6. 设置精度参数 ----------
|
|
||||||
echo "[6/8] 设置导航精度参数 (xy_goal_tolerance=0.05m)..."
|
|
||||||
source /opt/ros/humble/setup.bash 2>/dev/null || true
|
|
||||||
cd "$AGV_ROS2_DIR"
|
|
||||||
source install/setup.bash
|
|
||||||
|
|
||||||
for NODE in /controller_server /bt_navigator /planner_server; do
|
|
||||||
ROS_DOMAIN_ID=$ROS_DOMAIN_ID_VAL timeout 1 ros2 param set $NODE general_goal_checker.xy_goal_tolerance 0.05 2>/dev/null || true
|
|
||||||
ROS_DOMAIN_ID=$ROS_DOMAIN_ID_VAL timeout 1 ros2 param set $NODE general_goal_checker.yaw_goal_tolerance 0.05 2>/dev/null || true
|
|
||||||
done
|
|
||||||
ROS_DOMAIN_ID=$ROS_DOMAIN_ID_VAL timeout 1 ros2 param set /controller_server FollowPath.xy_goal_tolerance 0.05 2>/dev/null || true
|
|
||||||
ROS_DOMAIN_ID=$ROS_DOMAIN_ID_VAL timeout 1 ros2 param set /controller_server general_goal_checker.stateful True 2>/dev/null || true
|
|
||||||
ROS_DOMAIN_ID=$ROS_DOMAIN_ID_VAL timeout 1 ros2 param set /controller_server FollowPath.stateful True 2>/dev/null || true
|
|
||||||
echo " ✅ 精度参数已设置"
|
|
||||||
|
|
||||||
# ---------- 7. 启动 Flask ----------
|
|
||||||
echo "[7/8] 启动 Flask API..."
|
|
||||||
export ROS_DOMAIN_ID=1
|
|
||||||
cd "$AGV_APP_DIR"
|
|
||||||
nohup python3 app.py > /tmp/agv_flask.log 2>&1 &
|
|
||||||
FLASK_PID=$!
|
|
||||||
echo " Flask PID: $FLASK_PID"
|
|
||||||
sleep 4
|
|
||||||
|
|
||||||
# ---------- 8. 最终全面验证 ----------
|
|
||||||
echo ""
|
|
||||||
echo "=========================================="
|
|
||||||
echo " 系统全面验证"
|
|
||||||
echo "=========================================="
|
|
||||||
|
|
||||||
# 8a. 验证 ros2 topic list(核心指标)
|
|
||||||
echo ""
|
|
||||||
echo "验证 ros2 topic list..."
|
|
||||||
TOPIC_COUNT=$(source /opt/ros/humble/setup.bash && ROS_DOMAIN_ID=$ROS_DOMAIN_ID_VAL timeout 5 ros2 topic list 2>/dev/null | wc -l || echo 0)
|
|
||||||
echo " 话题数量: $TOPIC_COUNT"
|
|
||||||
if [ "$TOPIC_COUNT" -gt 10 ]; then
|
|
||||||
echo " ✅ ros2 daemon 正常 (${TOPIC_COUNT} 个话题)"
|
|
||||||
else
|
|
||||||
echo " ❌ ros2 topic list 异常 (${TOPIC_COUNT} 个话题,可能 DDS 有问题)"
|
|
||||||
echo " 手动执行: rm -rf /dev/shm/fastrtps_* && ros2 daemon stop && ros2 daemon start"
|
|
||||||
fi
|
|
||||||
|
|
||||||
# 8b. 验证关键话题
|
|
||||||
echo ""
|
|
||||||
echo "验证关键话题..."
|
|
||||||
for TOPIC in /odom /scan /cmd_vel /tf; do
|
|
||||||
if ROS_DOMAIN_ID=$ROS_DOMAIN_ID_VAL ros2 topic list 2>/dev/null | grep -q "$TOPIC"; then
|
|
||||||
echo " ✅ $TOPIC"
|
|
||||||
else
|
|
||||||
echo " ⚠️ $TOPIC 未找到"
|
|
||||||
fi
|
|
||||||
done
|
|
||||||
|
|
||||||
# 8c. 验证进程数量(确保没有重复启动)
|
|
||||||
echo ""
|
|
||||||
echo "验证进程数量..."
|
|
||||||
BRINGUP_PROCS=$(ps aux | grep -E 'agv_pro_node|lslidar_driver_node' | grep -v grep | wc -l || echo 0)
|
|
||||||
echo " AGV 核心进程: $BRINGUP_PROCS (应为 2)"
|
|
||||||
if [ "$BRINGUP_PROCS" -eq 2 ]; then
|
|
||||||
echo " ✅ 进程数量正常(无重复)"
|
|
||||||
elif [ "$BRINGUP_PROCS" -gt 2 ]; then
|
|
||||||
echo " ⚠️ 发现 $BRINGUP_PROCS 个核心进程(可能有残留),建议重启"
|
|
||||||
else
|
|
||||||
echo " ⚠️ 进程数量异常"
|
|
||||||
fi
|
|
||||||
|
|
||||||
# 8d. FastRTPS 共享内存状态
|
|
||||||
echo ""
|
|
||||||
echo "FastRTPS 共享内存状态:"
|
|
||||||
FASTRTPS_NEW=$(ls /dev/shm/fastrtps_* 2>/dev/null | wc -l || echo 0)
|
|
||||||
echo " 当前文件数: $FASTRTPS_NEW (正常运行时会有一些)"
|
|
||||||
|
|
||||||
# 8e. Flask API 测试
|
|
||||||
echo ""
|
|
||||||
echo "验证 Flask API..."
|
|
||||||
FLASK_RUNNING=$(ps aux | grep "[p]ython3 app.py" | wc -l || echo 0)
|
|
||||||
if [ "$FLASK_RUNNING" -gt 0 ]; then
|
|
||||||
echo " ✅ Flask 进程运行中"
|
|
||||||
else
|
|
||||||
echo " ❌ Flask 未运行"
|
|
||||||
fi
|
|
||||||
|
|
||||||
# ---------- 完成 ----------
|
|
||||||
echo ""
|
|
||||||
echo "=========================================="
|
|
||||||
echo " ✅ 启动完成"
|
|
||||||
echo "=========================================="
|
|
||||||
echo ""
|
|
||||||
echo " 进程状态:"
|
|
||||||
for PROC in "bringup:$BRINGUP_PID" "Nav2:$NAV2_PID" "fixer:$FIXER_PID" "Flask:$FLASK_PID"; do
|
|
||||||
NAME="${PROC%%:*}"
|
|
||||||
PID="${PROC##*:}"
|
|
||||||
STATUS=$(ps aux | grep -w "$PID" | grep -v grep | awk '{print "运行中"}' || echo '已退出')
|
|
||||||
echo " $NAME : $STATUS"
|
|
||||||
done
|
|
||||||
echo ""
|
|
||||||
echo " 日志文件:"
|
|
||||||
echo " bringup : /tmp/ros2_bringup.log"
|
|
||||||
echo " Nav2 : /tmp/ros2_nav2.log"
|
|
||||||
echo " fixer : /tmp/scan_fixer.log"
|
|
||||||
echo " Flask : /tmp/agv_flask.log"
|
|
||||||
echo ""
|
|
||||||
echo " 如果仍有问题,请依次执行:"
|
|
||||||
echo " 1. ./stop_all.sh"
|
|
||||||
echo " 2. rm -rf /dev/shm/fastrtps_*"
|
|
||||||
echo " 3. ./start_all.sh"
|
|
||||||
@@ -1,9 +0,0 @@
|
|||||||
#!/bin/bash
|
|
||||||
# Flask 启动脚本 - 杀掉旧进程并重启
|
|
||||||
|
|
||||||
pkill -f "python.*app.py" 2>/dev/null
|
|
||||||
sleep 1
|
|
||||||
|
|
||||||
cd /home/elephant/work/agv_app
|
|
||||||
nohup python3 app.py > /tmp/agv_flask.log 2>&1 &
|
|
||||||
echo "Flask started, PID: $!"
|
|
||||||
@@ -59,6 +59,49 @@ a:hover { text-decoration: underline; }
|
|||||||
.status-item.paused { background: #3a2a1a; color: #ff9800; }
|
.status-item.paused { background: #3a2a1a; color: #ff9800; }
|
||||||
.status-item.idle { background: #2a2a2a; color: #9aa0a6; }
|
.status-item.idle { background: #2a2a2a; color: #9aa0a6; }
|
||||||
|
|
||||||
|
/* ========== 环境切换开关 ========== */
|
||||||
|
.env-toggle {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 6px;
|
||||||
|
cursor: pointer;
|
||||||
|
user-select: none;
|
||||||
|
}
|
||||||
|
.env-label {
|
||||||
|
font-size: 12px;
|
||||||
|
font-weight: 500;
|
||||||
|
min-width: 48px;
|
||||||
|
text-align: right;
|
||||||
|
transition: color 0.2s;
|
||||||
|
}
|
||||||
|
.env-label.test { color: #ff9800; }
|
||||||
|
.env-label.prod { color: #4fc3f7; }
|
||||||
|
.toggle-switch {
|
||||||
|
width: 40px;
|
||||||
|
height: 22px;
|
||||||
|
background: #3a3a3a;
|
||||||
|
border-radius: 11px;
|
||||||
|
position: relative;
|
||||||
|
transition: background 0.25s;
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
.toggle-switch.active {
|
||||||
|
background: #ff9800;
|
||||||
|
}
|
||||||
|
.toggle-knob {
|
||||||
|
width: 18px;
|
||||||
|
height: 18px;
|
||||||
|
background: #fff;
|
||||||
|
border-radius: 50%;
|
||||||
|
position: absolute;
|
||||||
|
top: 2px;
|
||||||
|
left: 2px;
|
||||||
|
transition: left 0.25s;
|
||||||
|
}
|
||||||
|
.toggle-switch.active .toggle-knob {
|
||||||
|
left: 20px;
|
||||||
|
}
|
||||||
|
|
||||||
/* ========== 卡片 ========== */
|
/* ========== 卡片 ========== */
|
||||||
.card {
|
.card {
|
||||||
background: #1a2332;
|
background: #1a2332;
|
||||||
@@ -465,6 +508,10 @@ a:hover { text-decoration: underline; }
|
|||||||
aspect-ratio: 4/3;
|
aspect-ratio: 4/3;
|
||||||
object-fit: cover;
|
object-fit: cover;
|
||||||
}
|
}
|
||||||
|
.camera-img.arm {
|
||||||
|
/* no flip */
|
||||||
|
}
|
||||||
|
|
||||||
.camera-placeholder {
|
.camera-placeholder {
|
||||||
width: 100%;
|
width: 100%;
|
||||||
aspect-ratio: 4/3;
|
aspect-ratio: 4/3;
|
||||||
@@ -1057,10 +1104,10 @@ a:hover { text-decoration: underline; }
|
|||||||
@keyframes navPulse { 0%,100% { border-color: #4caf50; } 50% { border-color: #1b5e20; } }
|
@keyframes navPulse { 0%,100% { border-color: #4caf50; } 50% { border-color: #1b5e20; } }
|
||||||
|
|
||||||
/* 机器单元格状态 */
|
/* 机器单元格状态 */
|
||||||
.machine-cell { min-height: 62px; padding: 6px 8px; display: flex; flex-direction: column; align-items: center; justify-content: center; gap: 4px; }
|
.machine-cell { min-height: 62px; padding: 6px 8px; display: flex; flex-direction: column; align-items: center; justify-content: center; gap: 4px; overflow: hidden; }
|
||||||
.machine-label { font-size: 12px; font-weight: 600; color: #ccc; }
|
.machine-label { font-size: 12px; font-weight: 600; color: #ccc; }
|
||||||
.machine-steps-mini { display: flex; gap: 8px; font-size: 13px; }
|
.machine-steps-mini { display: flex; gap: 8px; font-size: 13px; }
|
||||||
.machine-qr-mini { font-size: 10px; color: #4fc3f7; max-width: 80px; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
|
.machine-qr-mini { font-size: 10px; color: #4fc3f7; max-width: 100%; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
|
||||||
.empty-cell { color: #445566; font-size: 12px; }
|
.empty-cell { color: #445566; font-size: 12px; }
|
||||||
|
|
||||||
/* 步骤圆点 */
|
/* 步骤圆点 */
|
||||||
@@ -1076,3 +1123,185 @@ a:hover { text-decoration: underline; }
|
|||||||
.machine-cell.mstatus-pending { background: #141e28; border-color: #2a3a4a; }
|
.machine-cell.mstatus-pending { background: #141e28; border-color: #2a3a4a; }
|
||||||
.machine-cell.mstatus-active { background: #1a2535; border-color: #4fc3f7; }
|
.machine-cell.mstatus-active { background: #1a2535; border-color: #4fc3f7; }
|
||||||
.machine-cell.mstatus-completed { background: #152522; border-color: #2e7d32; }
|
.machine-cell.mstatus-completed { background: #152522; border-color: #2e7d32; }
|
||||||
|
|
||||||
|
/* ===== 报关单选择 ===== */
|
||||||
|
.customs-panel {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 4px;
|
||||||
|
}
|
||||||
|
.customs-row {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 8px;
|
||||||
|
}
|
||||||
|
.customs-select {
|
||||||
|
flex: 1;
|
||||||
|
max-width: 400px;
|
||||||
|
padding: 10px 12px;
|
||||||
|
background: #1a2535;
|
||||||
|
border: 1px solid #2a3a4a;
|
||||||
|
border-radius: 8px;
|
||||||
|
color: #e0e0e0;
|
||||||
|
font-size: 14px;
|
||||||
|
outline: none;
|
||||||
|
}
|
||||||
|
.customs-select:focus {
|
||||||
|
border-color: #4fc3f7;
|
||||||
|
}
|
||||||
|
.customs-select:disabled {
|
||||||
|
opacity: 0.5;
|
||||||
|
cursor: not-allowed;
|
||||||
|
}
|
||||||
|
.customs-select option {
|
||||||
|
background: #1a2535;
|
||||||
|
color: #e0e0e0;
|
||||||
|
}
|
||||||
|
.customs-select:disabled option {
|
||||||
|
opacity: 1;
|
||||||
|
}
|
||||||
|
.customs-info {
|
||||||
|
font-size: 13px;
|
||||||
|
color: #8899aa;
|
||||||
|
}
|
||||||
|
.customs-badge {
|
||||||
|
font-size: 13px;
|
||||||
|
}
|
||||||
|
.customs-actions {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 4px;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ===== 数据表格 ===== */
|
||||||
|
.table-wrapper {
|
||||||
|
overflow-x: auto;
|
||||||
|
margin-top: 8px;
|
||||||
|
}
|
||||||
|
.data-table {
|
||||||
|
width: 100%;
|
||||||
|
border-collapse: collapse;
|
||||||
|
font-size: 13px;
|
||||||
|
}
|
||||||
|
.data-table th {
|
||||||
|
text-align: left;
|
||||||
|
padding: 10px 12px;
|
||||||
|
background: #0f1923;
|
||||||
|
color: #8899aa;
|
||||||
|
font-weight: 500;
|
||||||
|
border-bottom: 1px solid #2a3a4a;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
.data-table td {
|
||||||
|
padding: 10px 12px;
|
||||||
|
border-bottom: 1px solid #1a2a3a;
|
||||||
|
color: #ccc;
|
||||||
|
}
|
||||||
|
.data-table tbody tr:hover {
|
||||||
|
background: #1a2a3a;
|
||||||
|
}
|
||||||
|
.clickable-row {
|
||||||
|
cursor: pointer;
|
||||||
|
transition: background 0.15s;
|
||||||
|
}
|
||||||
|
.clickable-row:hover {
|
||||||
|
background: #1a2535 !important;
|
||||||
|
}
|
||||||
|
.row-selected {
|
||||||
|
background: #142a3a !important;
|
||||||
|
border-left: 3px solid #4fc3f7;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ===== Badge 状态标签 ===== */
|
||||||
|
.badge {
|
||||||
|
display: inline-block;
|
||||||
|
padding: 2px 8px;
|
||||||
|
border-radius: 4px;
|
||||||
|
font-size: 11px;
|
||||||
|
font-weight: 500;
|
||||||
|
}
|
||||||
|
.badge-unknown { background: #2a3441; color: #8899aa; }
|
||||||
|
.badge-normal { background: #1a3a2a; color: #4caf50; }
|
||||||
|
.badge-active { background: #1a3050; color: #4fc3f7; }
|
||||||
|
.badge-finished { background: #1a3a2a; color: #4caf50; }
|
||||||
|
.badge-waiting { background: #3a3020; color: #ffc107; }
|
||||||
|
.badge-error { background: #3a1a1a; color: #f44336; }
|
||||||
|
|
||||||
|
/* ===== 分页控件 ===== */
|
||||||
|
.pagination {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
padding: 16px 0 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ===== 查验进度 ===== */
|
||||||
|
.inspection-grid {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: repeat(auto-fill, minmax(220px, 1fr));
|
||||||
|
gap: 10px;
|
||||||
|
margin-top: 8px;
|
||||||
|
}
|
||||||
|
.inspection-item {
|
||||||
|
background: rgba(26, 26, 46, 0.7);
|
||||||
|
border-radius: 8px;
|
||||||
|
padding: 12px;
|
||||||
|
border: 1px solid #2a2a3e;
|
||||||
|
transition: all 0.3s;
|
||||||
|
}
|
||||||
|
.inspection-item.insp-done {
|
||||||
|
border-color: #4caf50;
|
||||||
|
background: rgba(76, 175, 80, 0.08);
|
||||||
|
}
|
||||||
|
.inspection-item.insp-active {
|
||||||
|
border-color: #ff9800;
|
||||||
|
background: rgba(255, 152, 0, 0.08);
|
||||||
|
}
|
||||||
|
.insp-name {
|
||||||
|
font-weight: bold;
|
||||||
|
font-size: 14px;
|
||||||
|
margin-bottom: 4px;
|
||||||
|
color: #e0e0e0;
|
||||||
|
}
|
||||||
|
.insp-code {
|
||||||
|
font-family: monospace;
|
||||||
|
font-size: 12px;
|
||||||
|
color: #8899aa;
|
||||||
|
margin-bottom: 2px;
|
||||||
|
}
|
||||||
|
.insp-spec {
|
||||||
|
font-size: 11px;
|
||||||
|
color: #667788;
|
||||||
|
margin-bottom: 8px;
|
||||||
|
}
|
||||||
|
.insp-count {
|
||||||
|
font-size: 20px;
|
||||||
|
font-weight: bold;
|
||||||
|
margin-bottom: 6px;
|
||||||
|
display: flex;
|
||||||
|
align-items: baseline;
|
||||||
|
gap: 4px;
|
||||||
|
}
|
||||||
|
.insp-num {
|
||||||
|
color: #4fc3f7;
|
||||||
|
}
|
||||||
|
.insp-sep {
|
||||||
|
color: #667788;
|
||||||
|
font-size: 14px;
|
||||||
|
}
|
||||||
|
.insp-total {
|
||||||
|
color: #8899aa;
|
||||||
|
font-size: 14px;
|
||||||
|
}
|
||||||
|
.insp-bar {
|
||||||
|
height: 4px;
|
||||||
|
background: #0a0a14;
|
||||||
|
border-radius: 2px;
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
.insp-fill {
|
||||||
|
height: 100%;
|
||||||
|
background: linear-gradient(90deg, #4fc3f7, #4caf50);
|
||||||
|
border-radius: 2px;
|
||||||
|
transition: width 0.5s ease;
|
||||||
|
}
|
||||||
|
|||||||
@@ -18,8 +18,11 @@ createApp({
|
|||||||
agvCameraSrc: '/api/camera/refresh?t=' + Date.now(),
|
agvCameraSrc: '/api/camera/refresh?t=' + Date.now(),
|
||||||
armCameraSrc: '/api/camera/arm_preview?t=' + Date.now(),
|
armCameraSrc: '/api/camera/arm_preview?t=' + Date.now(),
|
||||||
agvCameraError: false,
|
agvCameraError: false,
|
||||||
|
hasAgvCamera: false, // AGV 车体是否有可用相机
|
||||||
armCameraError: false,
|
armCameraError: false,
|
||||||
reconnectingDevice: null
|
reconnectingDevice: null,
|
||||||
|
// 环境切换
|
||||||
|
testMode: true,
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
computed: {
|
computed: {
|
||||||
@@ -36,6 +39,8 @@ createApp({
|
|||||||
},
|
},
|
||||||
mounted() {
|
mounted() {
|
||||||
this.refresh()
|
this.refresh()
|
||||||
|
this.refreshCameraCapabilities()
|
||||||
|
this.loadEnvMode()
|
||||||
setInterval(this.refreshStatus, 3000)
|
setInterval(this.refreshStatus, 3000)
|
||||||
this.refreshCams()
|
this.refreshCams()
|
||||||
setInterval(() => this.refreshCams(), 2000)
|
setInterval(() => this.refreshCams(), 2000)
|
||||||
@@ -47,6 +52,17 @@ createApp({
|
|||||||
this.armCameraSrc = '/api/camera/arm_preview?t=' + Date.now()
|
this.armCameraSrc = '/api/camera/arm_preview?t=' + Date.now()
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
async refreshCameraCapabilities() {
|
||||||
|
try {
|
||||||
|
const res = await fetch(API + '/api/camera/capabilities')
|
||||||
|
const data = await res.json()
|
||||||
|
this.hasAgvCamera = data.has_agv_camera
|
||||||
|
} catch (e) { this.hasAgvCamera = false }
|
||||||
|
},
|
||||||
|
refreshAgvCamera() {
|
||||||
|
this.agvCameraSrc = '/api/camera/refresh?t=' + Date.now()
|
||||||
|
this.agvCameraError = false
|
||||||
|
},
|
||||||
async refresh() {
|
async refresh() {
|
||||||
await this.refreshStatus()
|
await this.refreshStatus()
|
||||||
await this.loadPoints()
|
await this.loadPoints()
|
||||||
@@ -58,6 +74,10 @@ createApp({
|
|||||||
this.agvConnected = data.agv_connected
|
this.agvConnected = data.agv_connected
|
||||||
this.armConnected = data.arm_connected
|
this.armConnected = data.arm_connected
|
||||||
this.cameraOpened = data.camera_opened
|
this.cameraOpened = data.camera_opened
|
||||||
|
// 尝试从后端获取摄像头能力,若无字段则保持默认 false
|
||||||
|
if (data.has_agv_camera !== undefined) {
|
||||||
|
this.hasAgvCamera = data.has_agv_camera
|
||||||
|
}
|
||||||
this.armCameraOpened = data.arm_camera_opened
|
this.armCameraOpened = data.arm_camera_opened
|
||||||
this.mapLoaded = data.map_loaded
|
this.mapLoaded = data.map_loaded
|
||||||
this.currentState = data.state || 'idle'
|
this.currentState = data.state || 'idle'
|
||||||
@@ -116,6 +136,36 @@ createApp({
|
|||||||
} else {
|
} else {
|
||||||
window.location.href = '/running'
|
window.location.href = '/running'
|
||||||
}
|
}
|
||||||
}
|
},
|
||||||
|
async loadEnvMode() {
|
||||||
|
try {
|
||||||
|
const res = await fetch(API + '/api/config/mode')
|
||||||
|
const data = await res.json()
|
||||||
|
if (data.ok) {
|
||||||
|
this.testMode = data.test_mode
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
console.error('加载环境配置失败:', e)
|
||||||
|
}
|
||||||
|
},
|
||||||
|
async toggleEnvMode() {
|
||||||
|
const newMode = !this.testMode
|
||||||
|
try {
|
||||||
|
const res = await fetch(API + '/api/config/mode', {
|
||||||
|
method: 'POST',
|
||||||
|
headers: {'Content-Type': 'application/json'},
|
||||||
|
body: JSON.stringify({test_mode: newMode})
|
||||||
|
})
|
||||||
|
const data = await res.json()
|
||||||
|
if (data.ok) {
|
||||||
|
this.testMode = data.test_mode
|
||||||
|
alert('已切换至: ' + data.label)
|
||||||
|
} else {
|
||||||
|
alert('切换失败: ' + (data.error || '未知错误'))
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
alert('切换请求失败: ' + e.message)
|
||||||
|
}
|
||||||
|
},
|
||||||
}
|
}
|
||||||
}).mount('#app')
|
}).mount('#app')
|
||||||
|
|||||||
@@ -9,6 +9,7 @@ createApp({
|
|||||||
tasks: [],
|
tasks: [],
|
||||||
report: null,
|
report: null,
|
||||||
armCameraOpened: false,
|
armCameraOpened: false,
|
||||||
|
hasAgvCamera: false,
|
||||||
agvPreviewUrl: API + '/api/camera/preview',
|
agvPreviewUrl: API + '/api/camera/preview',
|
||||||
armPreviewUrl: '',
|
armPreviewUrl: '',
|
||||||
polling: null,
|
polling: null,
|
||||||
@@ -27,14 +28,17 @@ createApp({
|
|||||||
errorMsg: '',
|
errorMsg: '',
|
||||||
waitingStep: false,
|
waitingStep: false,
|
||||||
stepLabel: '',
|
stepLabel: '',
|
||||||
|
qrMessage: '所有姿态均未识别到二维码,请手动输入:',
|
||||||
// 任务步骤控制开关(机械臂初始化并入AGV移动)
|
// 任务步骤控制开关(机械臂初始化并入AGV移动)
|
||||||
agvMoveEnabled: true,
|
agvMoveEnabled: true,
|
||||||
qrScanEnabled: true,
|
qrScanEnabled: true,
|
||||||
frontPhotoEnabled: true,
|
frontPhotoEnabled: true,
|
||||||
backPhotoEnabled: true,
|
backPhotoEnabled: true,
|
||||||
// 速度控制
|
// 速度控制
|
||||||
agvSpeed: 0.5,
|
agvSpeed: 1.0,
|
||||||
armSpeed: 500,
|
armSpeed: 1000,
|
||||||
|
// 查验
|
||||||
|
inspection: null,
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
computed: {
|
computed: {
|
||||||
@@ -50,6 +54,14 @@ createApp({
|
|||||||
}
|
}
|
||||||
return map[this.missionState] || '未知'
|
return map[this.missionState] || '未知'
|
||||||
},
|
},
|
||||||
|
inspectionTotal() {
|
||||||
|
if (!this.inspection || !this.inspection.items) return 0
|
||||||
|
return this.inspection.items.reduce((s, i) => s + (i.inspected || 0), 0)
|
||||||
|
},
|
||||||
|
inspectionTarget() {
|
||||||
|
if (!this.inspection || !this.inspection.items) return 0
|
||||||
|
return this.inspection.items.reduce((s, i) => s + (i.quantify || 0), 0)
|
||||||
|
},
|
||||||
},
|
},
|
||||||
mounted() {
|
mounted() {
|
||||||
this.poll()
|
this.poll()
|
||||||
@@ -63,13 +75,20 @@ createApp({
|
|||||||
try {
|
try {
|
||||||
const res = await fetch(API + '/api/status')
|
const res = await fetch(API + '/api/status')
|
||||||
const data = await res.json()
|
const data = await res.json()
|
||||||
if (!this.armCameraOpened) {
|
const opened = data.arm_camera_opened
|
||||||
this.armPreviewUrl = ''
|
if (opened !== this.armCameraOpened || (opened && !this.armPreviewUrl)) {
|
||||||
} else if (!this.armPreviewUrl) {
|
this.armCameraOpened = opened
|
||||||
this.armPreviewUrl = API + '/api/camera/arm_preview'
|
this.armPreviewUrl = opened ? API + '/api/camera/arm_preview' : ''
|
||||||
}
|
}
|
||||||
} catch (e) {}
|
} catch (e) {}
|
||||||
},
|
},
|
||||||
|
async checkAgvCameraCapabilities() {
|
||||||
|
try {
|
||||||
|
const res = await fetch(API + '/api/camera/capabilities')
|
||||||
|
const data = await res.json()
|
||||||
|
this.hasAgvCamera = data.has_agv_camera
|
||||||
|
} catch (e) { this.hasAgvCamera = false }
|
||||||
|
},
|
||||||
poll() {
|
poll() {
|
||||||
this.refresh()
|
this.refresh()
|
||||||
this.pollLogs()
|
this.pollLogs()
|
||||||
@@ -93,7 +112,11 @@ createApp({
|
|||||||
if (data.grid) this.missionGrid = data.grid
|
if (data.grid) this.missionGrid = data.grid
|
||||||
if (data.point_status) this.pointStatus = data.point_status
|
if (data.point_status) this.pointStatus = data.point_status
|
||||||
if (data.machine_status) this.machineStatus = data.machine_status
|
if (data.machine_status) this.machineStatus = data.machine_status
|
||||||
|
if (data.inspection) this.inspection = data.inspection
|
||||||
this.armCameraOpened = data.arm_camera_opened
|
this.armCameraOpened = data.arm_camera_opened
|
||||||
|
if (this.armCameraOpened && !this.armPreviewUrl) {
|
||||||
|
this.armPreviewUrl = API + '/api/camera/arm_preview'
|
||||||
|
}
|
||||||
|
|
||||||
// 错误弹窗
|
// 错误弹窗
|
||||||
if (data.waiting_error) {
|
if (data.waiting_error) {
|
||||||
@@ -111,6 +134,11 @@ createApp({
|
|||||||
this.waitingStep = false
|
this.waitingStep = false
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// QR 弹窗消息
|
||||||
|
if (data.qr_message) {
|
||||||
|
this.qrMessage = data.qr_message
|
||||||
|
}
|
||||||
|
|
||||||
// QR 弹窗(防止提交后重复弹出)
|
// QR 弹窗(防止提交后重复弹出)
|
||||||
if (this.missionState !== 'waiting_qr') {
|
if (this.missionState !== 'waiting_qr') {
|
||||||
this.qrSubmitting = false
|
this.qrSubmitting = false
|
||||||
@@ -118,6 +146,9 @@ createApp({
|
|||||||
if (this.missionState === 'waiting_qr' && !this.showQrModal && !this.qrSubmitting) {
|
if (this.missionState === 'waiting_qr' && !this.showQrModal && !this.qrSubmitting) {
|
||||||
this.showQrModal = true
|
this.showQrModal = true
|
||||||
this.qrValue = ''
|
this.qrValue = ''
|
||||||
|
if (!this.qrMessage) {
|
||||||
|
this.qrMessage = '所有姿态均未识别到二维码,请手动输入:'
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// 完成后获取报告
|
// 完成后获取报告
|
||||||
@@ -145,6 +176,11 @@ createApp({
|
|||||||
},
|
},
|
||||||
async startMission() {
|
async startMission() {
|
||||||
if (this.missionState !== 'idle') return
|
if (this.missionState !== 'idle') return
|
||||||
|
// 没有设置报关单时阻止启动(后端也会校验,这里提前友好提示)
|
||||||
|
if (!this.inspection) {
|
||||||
|
alert('⚠️ 请先在「设置→报关单」中选择报关单并点击「开始查验」')
|
||||||
|
return
|
||||||
|
}
|
||||||
this.logs = []
|
this.logs = []
|
||||||
this.progress = 0
|
this.progress = 0
|
||||||
this.report = null
|
this.report = null
|
||||||
@@ -175,6 +211,11 @@ createApp({
|
|||||||
},
|
},
|
||||||
async startSingleStep() {
|
async startSingleStep() {
|
||||||
if (this.missionState !== 'idle') return
|
if (this.missionState !== 'idle') return
|
||||||
|
// 没有设置报关单时阻止启动(后端会校验,这里提前友好提示)
|
||||||
|
if (!this.inspection) {
|
||||||
|
alert('⚠️ 请先在「设置→报关单」中选择报关单并点击「开始查验」')
|
||||||
|
return
|
||||||
|
}
|
||||||
this.logs = []
|
this.logs = []
|
||||||
this.progress = 0
|
this.progress = 0
|
||||||
this.report = null
|
this.report = null
|
||||||
@@ -244,11 +285,28 @@ createApp({
|
|||||||
body: JSON.stringify({ qr: 'SKIP' })
|
body: JSON.stringify({ qr: 'SKIP' })
|
||||||
})
|
})
|
||||||
},
|
},
|
||||||
|
async rescanQr() {
|
||||||
|
this.showQrModal = false
|
||||||
|
this.qrValue = ''
|
||||||
|
this.qrSubmitting = true
|
||||||
|
await fetch(API + '/api/mission/manual-qr', {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify({ qr: 'RESCAN' })
|
||||||
|
})
|
||||||
|
// 4秒后允许弹窗重新出现(后端重试扫码约3秒)
|
||||||
|
setTimeout(() => { this.qrSubmitting = false }, 4000)
|
||||||
|
},
|
||||||
onAgvPreviewError(e) {
|
onAgvPreviewError(e) {
|
||||||
e.target.style.display = 'none'
|
e.target.style.display = 'none'
|
||||||
},
|
},
|
||||||
onArmPreviewError(e) {
|
onArmPreviewError(e) {
|
||||||
e.target.style.display = 'none'
|
// 重新加载:加时间戳避免缓存
|
||||||
|
const url = this.armPreviewUrl
|
||||||
|
if (url) {
|
||||||
|
const sep = url.includes('?') ? '&' : '?'
|
||||||
|
this.armPreviewUrl = url + sep + '_t=' + Date.now()
|
||||||
|
}
|
||||||
},
|
},
|
||||||
// ===== 网格任务显示方法 =====
|
// ===== 网格任务显示方法 =====
|
||||||
getPointStatus(pr, c) {
|
getPointStatus(pr, c) {
|
||||||
|
|||||||
@@ -45,7 +45,7 @@ createApp({
|
|||||||
agvConnected: false,
|
agvConnected: false,
|
||||||
agvBattery: null,
|
agvBattery: null,
|
||||||
agvPosition: null,
|
agvPosition: null,
|
||||||
agvSpeed: 0.5,
|
agvSpeed: 1.0,
|
||||||
agvMoveInterval: null,
|
agvMoveInterval: null,
|
||||||
agvCameraUrl: API + '/api/camera/refresh',
|
agvCameraUrl: API + '/api/camera/refresh',
|
||||||
agvCameraTimer: null,
|
agvCameraTimer: null,
|
||||||
@@ -57,9 +57,24 @@ createApp({
|
|||||||
qrScanning: false,
|
qrScanning: false,
|
||||||
qrConfigs: [],
|
qrConfigs: [],
|
||||||
qrScanningId: null,
|
qrScanningId: null,
|
||||||
|
showQrInputDialog: false,
|
||||||
|
qrInputId: null,
|
||||||
|
qrInputValue: '',
|
||||||
armCameraUrl: API + '/api/camera/arm_preview',
|
armCameraUrl: API + '/api/camera/arm_preview',
|
||||||
|
armSnapshotUrl: '',
|
||||||
|
showArmSnapshot: false,
|
||||||
|
armSnapshotLoading: false,
|
||||||
newQrName: '',
|
newQrName: '',
|
||||||
armInitialPose: [0, 0, 0, 0, 0, 0],
|
armInitialPose: [0, 0, 0, 0, 0, 0],
|
||||||
|
// 报关单
|
||||||
|
customsList: [],
|
||||||
|
customsLoading: false,
|
||||||
|
customsPage: 1,
|
||||||
|
customsPageSize: 15,
|
||||||
|
customsTotal: 0,
|
||||||
|
selectedCustomsId: '',
|
||||||
|
selectedCustomsName: '',
|
||||||
|
customsMachines: [],
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
mounted() {
|
mounted() {
|
||||||
@@ -67,9 +82,17 @@ createApp({
|
|||||||
this.refreshAngles()
|
this.refreshAngles()
|
||||||
this.loadQrConfigs()
|
this.loadQrConfigs()
|
||||||
this.nav2Timer = setInterval(this.refreshNavStatus, 3000)
|
this.nav2Timer = setInterval(this.refreshNavStatus, 3000)
|
||||||
this.armCameraUrl = API + '/api/camera/arm_preview?t=' + Date.now()
|
this.armSnapshotUrl = ""; this.armCameraUrl = API + '/api/camera/arm_preview?t=' + Date.now()
|
||||||
},
|
this.armSnapshotUrl = ""; this.armCameraUrl = API + "/api/camera/arm_preview?t=" + Date.now()
|
||||||
|
},
|
||||||
computed: {
|
computed: {
|
||||||
|
customsTotalPages() {
|
||||||
|
return Math.max(1, Math.ceil(this.customsTotal / this.customsPageSize))
|
||||||
|
},
|
||||||
|
customsPageData() {
|
||||||
|
// 前端显示 pagination data — 但我们在 API 后端做分页,所以这里只是引用
|
||||||
|
return this.customsList
|
||||||
|
},
|
||||||
hasQr() {
|
hasQr() {
|
||||||
return !!(this.selectedMachine && this.selectedMachine.qr)
|
return !!(this.selectedMachine && this.selectedMachine.qr)
|
||||||
},
|
},
|
||||||
@@ -358,8 +381,9 @@ createApp({
|
|||||||
} catch (e) { alert('导航失败: ' + e.message) }
|
} catch (e) { alert('导航失败: ' + e.message) }
|
||||||
},
|
},
|
||||||
async goToOrigin() {
|
async goToOrigin() {
|
||||||
if (!confirm('确认导航到原点 (0, 0, 0)?')) return
|
if (!confirm('确认先复原机械臂,再导航到原点 (0, 0, 0)?')) return
|
||||||
try {
|
try {
|
||||||
|
this.mapMsg = '正在复原机械臂并发送原点导航...'
|
||||||
const res = await fetch(API + '/api/navigate/to', {
|
const res = await fetch(API + '/api/navigate/to', {
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
headers: { 'Content-Type': 'application/json' },
|
headers: { 'Content-Type': 'application/json' },
|
||||||
@@ -367,7 +391,7 @@ createApp({
|
|||||||
})
|
})
|
||||||
const data = await res.json()
|
const data = await res.json()
|
||||||
if (data.ok) {
|
if (data.ok) {
|
||||||
this.mapMsg = '✅ 已发送导航到原点'
|
this.mapMsg = '✅ ' + (data.message || '已发送导航到原点')
|
||||||
} else {
|
} else {
|
||||||
this.mapMsg = '❌ ' + (data.error || '导航失败')
|
this.mapMsg = '❌ ' + (data.error || '导航失败')
|
||||||
}
|
}
|
||||||
@@ -1104,10 +1128,39 @@ createApp({
|
|||||||
if (data.model_name) msg += ' 匹配机型: ' + data.model_name
|
if (data.model_name) msg += ' 匹配机型: ' + data.model_name
|
||||||
else msg += ' 未匹配到机型'
|
else msg += ' 未匹配到机型'
|
||||||
alert(msg)
|
alert(msg)
|
||||||
} else { alert(data.error || '扫描失败') }
|
} else {
|
||||||
} catch (e) { alert('扫描失败: ' + e.message) }
|
// 自动扫描失败,弹出手动输入框
|
||||||
|
this.qrInputId = qrId
|
||||||
|
this.qrInputValue = ''
|
||||||
|
this.showQrInputDialog = true
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
this.qrInputId = qrId
|
||||||
|
this.qrInputValue = ''
|
||||||
|
this.showQrInputDialog = true
|
||||||
|
}
|
||||||
this.qrScanningId = null
|
this.qrScanningId = null
|
||||||
},
|
},
|
||||||
|
async submitManualQr() {
|
||||||
|
if (!this.qrInputId || !this.qrInputValue.trim()) return
|
||||||
|
try {
|
||||||
|
const res = await fetch(API + '/api/qr/configs/' + this.qrInputId, {
|
||||||
|
method: 'PUT',
|
||||||
|
headers: {'Content-Type':'application/json'},
|
||||||
|
body: JSON.stringify({ qr_value: this.qrInputValue.trim() })
|
||||||
|
})
|
||||||
|
const data = await res.json()
|
||||||
|
if (data.ok) {
|
||||||
|
await this.loadQrConfigs()
|
||||||
|
alert('✅ 二维码值已保存')
|
||||||
|
} else {
|
||||||
|
alert('保存失败')
|
||||||
|
}
|
||||||
|
} catch (e) { alert('保存失败: ' + e.message) }
|
||||||
|
this.showQrInputDialog = false
|
||||||
|
this.qrInputId = null
|
||||||
|
this.qrInputValue = ''
|
||||||
|
},
|
||||||
async applyQrAngles(qrId) {
|
async applyQrAngles(qrId) {
|
||||||
if (!this.armConnected) { alert('机械臂未连接'); return }
|
if (!this.armConnected) { alert('机械臂未连接'); return }
|
||||||
const q = this.qrConfigs.find(x => x.id === qrId)
|
const q = this.qrConfigs.find(x => x.id === qrId)
|
||||||
@@ -1126,6 +1179,12 @@ createApp({
|
|||||||
onArmPreviewError() {
|
onArmPreviewError() {
|
||||||
// 机械臂摄像头预览失败,静默处理
|
// 机械臂摄像头预览失败,静默处理
|
||||||
},
|
},
|
||||||
|
async captureArmSnapshot() {
|
||||||
|
this.armSnapshotLoading = true
|
||||||
|
this.armSnapshotUrl = API + '/api/camera/arm_refresh?t=' + Date.now()
|
||||||
|
this.showArmSnapshot = true
|
||||||
|
setTimeout(() => { this.armSnapshotLoading = false }, 500)
|
||||||
|
},
|
||||||
async agvResetCollision() {
|
async agvResetCollision() {
|
||||||
if (!this.agvConnected) {
|
if (!this.agvConnected) {
|
||||||
alert('AGV 未连接')
|
alert('AGV 未连接')
|
||||||
@@ -1146,5 +1205,121 @@ createApp({
|
|||||||
alert('❌ 复位请求失败: ' + e.message)
|
alert('❌ 复位请求失败: ' + e.message)
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
}
|
// ===== 报关单方法 =====
|
||||||
|
async loadCustomsList() {
|
||||||
|
this.customsLoading = true
|
||||||
|
try {
|
||||||
|
const url = API + '/api/customs/list?pageNum=' + this.customsPage + '&pageSize=' + this.customsPageSize + '&customsName=' + encodeURIComponent(this.customsName) + '&customsNo=' + encodeURIComponent(this.customsNo)
|
||||||
|
const res = await fetch(url)
|
||||||
|
const d = await res.json()
|
||||||
|
if (d.ok && d.data) {
|
||||||
|
const raw = d.data
|
||||||
|
let list = []
|
||||||
|
let total = 0
|
||||||
|
if (raw.rows) { list = raw.rows; total = raw.total || list.length }
|
||||||
|
else if (raw.records) { list = raw.records; total = raw.total || list.length }
|
||||||
|
else if (Array.isArray(raw)) { list = raw; total = list.length }
|
||||||
|
else if (raw.data && raw.data.rows) { list = raw.data.rows; total = raw.data.total || list.length }
|
||||||
|
else if (raw.data && raw.data.records) { list = raw.data.records; total = raw.data.total || list.length }
|
||||||
|
else if (raw.data && Array.isArray(raw.data)) { list = raw.data; total = list.length }
|
||||||
|
this.customsList = list
|
||||||
|
this.customsTotal = total || list.length
|
||||||
|
} else {
|
||||||
|
this.customsList = []
|
||||||
|
this.customsTotal = 0
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
console.error('加载报关单列表失败', e)
|
||||||
|
this.customsList = []
|
||||||
|
this.customsTotal = 0
|
||||||
|
} finally {
|
||||||
|
this.customsLoading = false
|
||||||
|
}
|
||||||
|
},
|
||||||
|
async selectCustomsRow(item) {
|
||||||
|
// 新数据结构: { customs:{id,orderId,..}, orderCode, drawCode }
|
||||||
|
const id = (item.customs && item.customs.id) || item.id || item.customsId || item.customs_id || ''
|
||||||
|
if (!id) return
|
||||||
|
this.selectedCustomsId = id
|
||||||
|
this.selectedCustomsName = (item.customs && item.customs.customsCode) || item.orderCode || item.drawCode || id
|
||||||
|
this.customsMachines = []
|
||||||
|
try {
|
||||||
|
const url = API + '/api/customs/machines?customsId=' + encodeURIComponent(id)
|
||||||
|
const res = await fetch(url)
|
||||||
|
const d = await res.json()
|
||||||
|
if (d.ok && d.data) {
|
||||||
|
const raw = d.data
|
||||||
|
let machines = []
|
||||||
|
// customsMachines 返回格式: {"code":"0","data":[{serialNumber,inventoryName,...}]}
|
||||||
|
if (raw.rows) { machines = raw.rows }
|
||||||
|
else if (raw.records) { machines = raw.records }
|
||||||
|
else if (raw.data && Array.isArray(raw.data)) { machines = raw.data }
|
||||||
|
else if (Array.isArray(raw)) { machines = raw }
|
||||||
|
else if (Array.isArray(raw.data)) { machines = raw.data }
|
||||||
|
this.customsMachines = machines
|
||||||
|
} else {
|
||||||
|
this.customsMachines = []
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
console.error('加载机器列表失败', e)
|
||||||
|
this.customsMachines = []
|
||||||
|
}
|
||||||
|
},
|
||||||
|
async startInspection(item) {
|
||||||
|
const id = (item.customs && item.customs.id) || item.id || item.customsId || item.customs_id || ''
|
||||||
|
const name = (item.customs && item.customs.customsCode) || item.orderCode || item.drawCode || id
|
||||||
|
if (!id) return
|
||||||
|
if (!confirm(`确定要对报关单「${name}」开始查验吗?\n点击确定后,运行页将以该报关单的机器进行查验。`)) return
|
||||||
|
try {
|
||||||
|
const res = await fetch(API + '/api/customs/inspection/start', {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify({ customsId: id, customsName: name })
|
||||||
|
})
|
||||||
|
const d = await res.json()
|
||||||
|
if (d.ok) {
|
||||||
|
alert(`✅ 查验已开始!\n报关单: ${name}\n机型: ${d.inspection.items.length} 种\n总数: ${d.inspection.items.reduce((s,i)=>s+i.quantify,0)} 台\n\n请前往「运行」页执行任务。`)
|
||||||
|
// 同时选中该报关单,显示机器列表
|
||||||
|
this.selectedCustomsId = id
|
||||||
|
this.selectedCustomsName = name
|
||||||
|
// 用 inspection items 填充 customsMachines 显示(聚合后)
|
||||||
|
this.customsMachines = d.inspection.items.map(it => ({
|
||||||
|
inventoryCode: it.inventoryCode,
|
||||||
|
inventoryName: it.inventoryName,
|
||||||
|
inventorySpecification: it.spec,
|
||||||
|
serialNumber: '',
|
||||||
|
quantify: it.quantify,
|
||||||
|
inspectionCount: it.inspected,
|
||||||
|
}))
|
||||||
|
} else {
|
||||||
|
alert('❌ 开始查验失败: ' + (d.error || '未知错误'))
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
alert('❌ 请求失败: ' + e.message)
|
||||||
|
}
|
||||||
|
},
|
||||||
|
async loadInspectionCounts() {
|
||||||
|
// 轮询查验计数,更新 customsMachines 的 inspectionCount
|
||||||
|
try {
|
||||||
|
const res = await fetch(API + '/api/customs/inspection')
|
||||||
|
const d = await res.json()
|
||||||
|
if (d.ok && d.inspection && this.customsMachines.length) {
|
||||||
|
for (const item of d.inspection.items) {
|
||||||
|
const match = this.customsMachines.find(m => m.inventoryCode === item.inventoryCode)
|
||||||
|
if (match) {
|
||||||
|
match.inspectionCount = item.inspected
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch (e) {}
|
||||||
|
},
|
||||||
|
},
|
||||||
|
watch: {
|
||||||
|
tab(newVal) {
|
||||||
|
if (newVal === 'customs' && this.customsMachines.length > 0) {
|
||||||
|
// 切换到报关单 tab 时刷新查验计数
|
||||||
|
this.loadInspectionCounts()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
}).mount('#app')
|
}).mount('#app')
|
||||||
|
|||||||
@@ -1,91 +0,0 @@
|
|||||||
#!/bin/bash
|
|
||||||
# ============================================================
|
|
||||||
# stop_all.sh - 关闭 AGV 拍摄系统所有相关进程
|
|
||||||
# 版本: v2.0
|
|
||||||
# 修复:
|
|
||||||
# - v2.0: 添加 FastRTPS 清理 + ros2 daemon 重置
|
|
||||||
# ============================================================
|
|
||||||
set -e
|
|
||||||
|
|
||||||
echo "=========================================="
|
|
||||||
echo " Robot AGV 全量停止"
|
|
||||||
echo "=========================================="
|
|
||||||
echo ""
|
|
||||||
|
|
||||||
# ---------- 1. 软杀所有相关进程 ----------
|
|
||||||
echo "[1/5] 软杀所有相关进程..."
|
|
||||||
pkill -f "python3 app.py" 2>/dev/null || true
|
|
||||||
pkill -f "agv_pro_bringup" 2>/dev/null || true
|
|
||||||
pkill -f "agv_pro_navigation2" 2>/dev/null || true
|
|
||||||
pkill -f "agv_pro_node" 2>/dev/null || true
|
|
||||||
pkill -f "lslidar_driver_node" 2>/dev/null || true
|
|
||||||
pkill -f "component_container" 2>/dev/null || true
|
|
||||||
pkill -f "fix_scan_timestamp" 2>/dev/null || true
|
|
||||||
pkill -f "clock_publisher" 2>/dev/null || true
|
|
||||||
pkill -f "robot_state_publisher" 2>/dev/null || true
|
|
||||||
pkill -f "start_all.sh" 2>/dev/null || true
|
|
||||||
sleep 2
|
|
||||||
|
|
||||||
# ---------- 2. 硬杀确保干净 ----------
|
|
||||||
echo "[2/5] 硬杀残留进程..."
|
|
||||||
pkill -9 -f "app.py" 2>/dev/null || true
|
|
||||||
pkill -9 -f "agv_pro_node" 2>/dev/null || true
|
|
||||||
pkill -9 -f "lslidar_driver_node" 2>/dev/null || true
|
|
||||||
pkill -9 -f "component_container" 2>/dev/null || true
|
|
||||||
pkill -9 -f "fix_scan_timestamp" 2>/dev/null || true
|
|
||||||
pkill -9 -f "agv_pro_bringup" 2>/dev/null || true
|
|
||||||
pkill -9 -f "agv_pro_navigation2" 2>/dev/null || true
|
|
||||||
sleep 1
|
|
||||||
|
|
||||||
# ---------- 3. 【关键】清理 FastRTPS 共享内存 ----------
|
|
||||||
echo "[3/5] 清理 FastRTPS 共享内存..."
|
|
||||||
FASTRTPS_COUNT=$(ls /dev/shm/fastrtps_* 2>/dev/null | wc -l || echo 0)
|
|
||||||
if [ "$FASTRTPS_COUNT" -gt 0 ]; then
|
|
||||||
rm -rf /dev/shm/fastrtps_*
|
|
||||||
echo " 已清理 $FASTRTPS_COUNT 个 FastRTPS 文件"
|
|
||||||
else
|
|
||||||
echo " 无 FastRTPS 文件残留"
|
|
||||||
fi
|
|
||||||
|
|
||||||
# 清理 scan_fixer 锁文件
|
|
||||||
rm -f /tmp/scan_fixer.lock
|
|
||||||
rm -f /tmp/clock_publisher.lock
|
|
||||||
echo " ✅ FastRTPS 清理完成"
|
|
||||||
|
|
||||||
# ---------- 4. 【关键】重置 ros2 daemon ----------
|
|
||||||
echo "[4/5] 重置 ros2 daemon..."
|
|
||||||
pkill -f "ros2-daemon" 2>/dev/null || true
|
|
||||||
pkill -9 -f "ros2-daemon" 2>/dev/null || true
|
|
||||||
sleep 2
|
|
||||||
source /opt/ros/humble/setup.bash 2>/dev/null || true
|
|
||||||
ros2 daemon stop 2>/dev/null || true
|
|
||||||
echo " ✅ ros2 daemon 已重置"
|
|
||||||
|
|
||||||
# ---------- 5. 验证清理结果 ----------
|
|
||||||
echo "[5/5] 验证清理结果..."
|
|
||||||
PROC_COUNT=$(ps aux | grep -E 'agv_pro_node|lslidar_driver_node|component_container|fix_scan_timestamp|clock_publisher|app.py|ros2-daemon' | grep -v grep | wc -l || echo 0)
|
|
||||||
FASTRTPS_LEFT=$(ls /dev/shm/fastrtps_* 2>/dev/null | wc -l || echo 0)
|
|
||||||
|
|
||||||
echo " 残留进程数: $PROC_COUNT"
|
|
||||||
echo " FastRTPS 文件数: $FASTRTPS_LEFT"
|
|
||||||
|
|
||||||
if [ "$PROC_COUNT" -eq 0 ] && [ "$FASTRTPS_LEFT" -eq 0 ]; then
|
|
||||||
echo ""
|
|
||||||
echo "=========================================="
|
|
||||||
echo " ✅ 停止完成 - 系统已完全清理"
|
|
||||||
echo "=========================================="
|
|
||||||
else
|
|
||||||
echo ""
|
|
||||||
echo "=========================================="
|
|
||||||
echo " ⚠️ 停止完成 - 部分残留可能需要手动清理"
|
|
||||||
echo "=========================================="
|
|
||||||
echo ""
|
|
||||||
echo " 手动清理命令(如需要):"
|
|
||||||
echo " pkill -9 -f 'agv_pro_node|lslidar|component_container'"
|
|
||||||
echo " pkill -9 -f 'fix_scan_timestamp|app.py'"
|
|
||||||
echo " pkill -9 -f 'ros2-daemon'"
|
|
||||||
echo " rm -rf /dev/shm/fastrtps_*"
|
|
||||||
fi
|
|
||||||
echo ""
|
|
||||||
echo " 现在可以安全运行 ./start_all.sh"
|
|
||||||
echo ""
|
|
||||||
-1045
File diff suppressed because it is too large
Load Diff
@@ -16,7 +16,13 @@
|
|||||||
<a href="/setting" class="nav-link">⚙️ 设置</a>
|
<a href="/setting" class="nav-link">⚙️ 设置</a>
|
||||||
<a href="/running" class="nav-link">▶️ 运行</a>
|
<a href="/running" class="nav-link">▶️ 运行</a>
|
||||||
</nav>
|
</nav>
|
||||||
<div class="status-bar">
|
<div class="status-bar" style="display:flex;align-items:center;gap:12px">
|
||||||
|
<label class="env-toggle" title="切换测试/正式环境">
|
||||||
|
<span class="env-label" :class="testMode ? 'test' : 'prod'">{% raw %}{{ testMode ? '🧪 测试' : '🏭 正式' }}{% endraw %}</span>
|
||||||
|
<div class="toggle-switch" @click="toggleEnvMode" :class="{active: testMode}">
|
||||||
|
<div class="toggle-knob"></div>
|
||||||
|
</div>
|
||||||
|
</label>
|
||||||
<span class="status-item" :class="statusClass">
|
<span class="status-item" :class="statusClass">
|
||||||
{% raw %}{{ statusText }}{% endraw %}
|
{% raw %}{{ statusText }}{% endraw %}
|
||||||
</span>
|
</span>
|
||||||
@@ -93,9 +99,10 @@
|
|||||||
<h2>📷 摄像头预览</h2>
|
<h2>📷 摄像头预览</h2>
|
||||||
<div class="camera-row">
|
<div class="camera-row">
|
||||||
<div class="camera-box">
|
<div class="camera-box">
|
||||||
<div class="camera-label">AGV 摄像头 <button class="btn btn-small" @click="agvCameraSrc='/api/camera/refresh?t='+Date.now(); agvCameraError=false">刷新</button></div>
|
<div class="camera-label">AGV 摄像头 <button class="btn btn-small" @click="refreshAgvCamera()">刷新</button></div>
|
||||||
<img v-if="cameraOpened && !agvCameraError" :src="agvCameraSrc" class="camera-img" @error="agvCameraError=true">
|
<img v-if="cameraOpened && hasAgvCamera && !agvCameraError" :src="agvCameraSrc" class="camera-img" @error="agvCameraError=true">
|
||||||
<div v-if="cameraOpened && agvCameraError" class="camera-placeholder">AGV 摄像头异常</div>
|
<div v-if="cameraOpened && agvCameraError && hasAgvCamera" class="camera-placeholder">AGV 摄像头异常</div>
|
||||||
|
<div v-if="cameraOpened && !hasAgvCamera" class="camera-placeholder">AGV 无可用彩色摄像头</div>
|
||||||
<div v-else-if="!cameraOpened" class="camera-placeholder">未打开(先点击连接设备)</div>
|
<div v-else-if="!cameraOpened" class="camera-placeholder">未打开(先点击连接设备)</div>
|
||||||
</div>
|
</div>
|
||||||
<div class="camera-box">
|
<div class="camera-box">
|
||||||
|
|||||||
@@ -4,7 +4,7 @@
|
|||||||
<meta charset="UTF-8">
|
<meta charset="UTF-8">
|
||||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||||
<title>运行监控 - AGV 拍摄系统</title>
|
<title>运行监控 - AGV 拍摄系统</title>
|
||||||
<link rel="stylesheet" href="/static/css/style.css?v=20260529a">
|
<link rel="stylesheet" href="/static/css/style.css?v=20260616a">
|
||||||
</head>
|
</head>
|
||||||
<body>
|
<body>
|
||||||
<div id="app">
|
<div id="app">
|
||||||
@@ -48,6 +48,30 @@
|
|||||||
</div>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
|
<!-- 查验进度 -->
|
||||||
|
<section class="card" v-if="inspection">
|
||||||
|
<h2>🔍 查验进度 — {% raw %}{{ inspection.customsName }}{% endraw %}</h2>
|
||||||
|
<p class="hint" style="margin-bottom:12px">
|
||||||
|
总进度: {% raw %}{{ inspectionTotal }}{% endraw %} / {% raw %}{{ inspectionTarget }}{% endraw %} 台
|
||||||
|
<span v-if="inspectionTotal >= inspectionTarget && inspectionTarget > 0" style="color:#4caf50;font-weight:bold"> ✅ 已完成</span>
|
||||||
|
</p>
|
||||||
|
<div class="inspection-grid">
|
||||||
|
<div v-for="(item, ii) in inspection.items" :key="ii" class="inspection-item" :class="{ 'insp-done': item.inspected >= item.quantify, 'insp-active': item.inspected > 0 && item.inspected < item.quantify }">
|
||||||
|
<div class="insp-name">{% raw %}{{ item.inventoryName }}{% endraw %}</div>
|
||||||
|
<div class="insp-code">{% raw %}{{ item.inventoryCode }}{% endraw %}</div>
|
||||||
|
<div class="insp-spec">{% raw %}{{ item.spec }}{% endraw %}</div>
|
||||||
|
<div class="insp-count">
|
||||||
|
<span class="insp-num">{% raw %}{{ item.inspected }}{% endraw %}</span>
|
||||||
|
<span class="insp-sep">/</span>
|
||||||
|
<span class="insp-total">{% raw %}{{ item.quantify }}{% endraw %}</span>
|
||||||
|
</div>
|
||||||
|
<div class="insp-bar">
|
||||||
|
<div class="insp-fill" :style="{width: (item.quantify > 0 ? (item.inspected / item.quantify * 100) : 0) + '%'}"></div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
<!-- 任务步骤控制开关 -->
|
<!-- 任务步骤控制开关 -->
|
||||||
<section class="card">
|
<section class="card">
|
||||||
<h2>🎛️ 任务步骤控制</h2>
|
<h2>🎛️ 任务步骤控制</h2>
|
||||||
@@ -140,7 +164,7 @@
|
|||||||
<span class="step-dot" :class="'dot-'+getMachineField(ri-1,c-1,'front')" title="正面照">📸正</span>
|
<span class="step-dot" :class="'dot-'+getMachineField(ri-1,c-1,'front')" title="正面照">📸正</span>
|
||||||
<span class="step-dot" :class="'dot-'+getMachineField(ri-1,c-1,'back')" title="背面照">📸背</span>
|
<span class="step-dot" :class="'dot-'+getMachineField(ri-1,c-1,'back')" title="背面照">📸背</span>
|
||||||
</div>
|
</div>
|
||||||
<div v-if="getMachineField(ri-1,c-1,'qr_val')" class="machine-qr-mini">🏷 {% raw %}{{ getMachineField(ri-1,c-1,'qr_val').substring(0,6) }}{% endraw %}</div>
|
<div v-if="getMachineField(ri-1,c-1,'qr_val')" class="machine-qr-mini">🏷 {% raw %}{{ getMachineField(ri-1,c-1,'qr_val') }}{% endraw %}</div>
|
||||||
</template>
|
</template>
|
||||||
<div v-else class="empty-cell">空</div>
|
<div v-else class="empty-cell">空</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -173,8 +197,9 @@
|
|||||||
<h2>📷 摄像头预览</h2>
|
<h2>📷 摄像头预览</h2>
|
||||||
<div class="camera-dual">
|
<div class="camera-dual">
|
||||||
<div class="camera-box">
|
<div class="camera-box">
|
||||||
<div class="camera-label">🎥 AGV 摄像头</div>
|
<div class="camera-label">🎥 AGV 摄像头 <span v-if="!hasAgvCamera" style="font-size:0.8em;color:#999">(不可用)</span></div>
|
||||||
<img :src="agvPreviewUrl" @error="onAgvPreviewError" class="camera-img">
|
<img v-if="hasAgvCamera" :src="agvPreviewUrl" @error="onAgvPreviewError" class="camera-img">
|
||||||
|
<div v-else class="camera-placeholder">AGV 无可用彩色摄像头</div>
|
||||||
</div>
|
</div>
|
||||||
<div class="camera-box" v-if="armCameraOpened">
|
<div class="camera-box" v-if="armCameraOpened">
|
||||||
<div class="camera-label">🦾 机械臂摄像头</div>
|
<div class="camera-label">🦾 机械臂摄像头</div>
|
||||||
@@ -201,9 +226,10 @@
|
|||||||
<div class="modal-overlay" v-if="showQrModal">
|
<div class="modal-overlay" v-if="showQrModal">
|
||||||
<div class="modal">
|
<div class="modal">
|
||||||
<h3>⌨️ 手动输入二维码</h3>
|
<h3>⌨️ 手动输入二维码</h3>
|
||||||
<p>所有姿态均未识别到二维码,请手动输入:</p>
|
<p>{% raw %}{{ qrMessage }}{% endraw %}</p>
|
||||||
<input type="text" v-model="qrValue" placeholder="输入二维码内容" autofocus @keyup.enter="submitQr">
|
<input type="text" v-model="qrValue" placeholder="输入二维码内容" autofocus @keyup.enter="submitQr">
|
||||||
<div class="modal-actions">
|
<div class="modal-actions">
|
||||||
|
<button class="btn btn-success" @click="rescanQr" style="margin-right:auto">🔄 重新扫描</button>
|
||||||
<button class="btn btn-primary" @click="submitQr">确认</button>
|
<button class="btn btn-primary" @click="submitQr">确认</button>
|
||||||
<button class="btn" @click="cancelQr">跳过</button>
|
<button class="btn" @click="cancelQr">跳过</button>
|
||||||
</div>
|
</div>
|
||||||
@@ -239,6 +265,6 @@
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<script src="/static/js/vue3.global.prod.js"></script>
|
<script src="/static/js/vue3.global.prod.js"></script>
|
||||||
<script src="/static/js/running.js?v=20260529a"></script>
|
<script src="/static/js/running.js?v=20260616c"></script>
|
||||||
</body>
|
</body>
|
||||||
</html>
|
</html>
|
||||||
+145
-12
@@ -4,7 +4,7 @@
|
|||||||
<meta charset="UTF-8">
|
<meta charset="UTF-8">
|
||||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||||
<title>设置 - AGV 拍摄系统</title>
|
<title>设置 - AGV 拍摄系统</title>
|
||||||
<link rel="stylesheet" href="/static/css/style.css?v=20260529b">
|
<link rel="stylesheet" href="/static/css/style.css?v=20260612b">
|
||||||
</head>
|
</head>
|
||||||
<body>
|
<body>
|
||||||
<div id="app">
|
<div id="app">
|
||||||
@@ -25,6 +25,7 @@
|
|||||||
<button class="tab" :class="{active: tab === 'model'}" @click="tab = 'model'">📦 机型配置</button>
|
<button class="tab" :class="{active: tab === 'model'}" @click="tab = 'model'">📦 机型配置</button>
|
||||||
<button class="tab" :class="{active: tab === 'arm'}" @click="tab = 'arm'">🤖 机械臂</button>
|
<button class="tab" :class="{active: tab === 'arm'}" @click="tab = 'arm'">🤖 机械臂</button>
|
||||||
<button class="tab" :class="{active: tab === 'agv'}" @click="tab = 'agv'">🚗 AGV控制</button>
|
<button class="tab" :class="{active: tab === 'agv'}" @click="tab = 'agv'">🚗 AGV控制</button>
|
||||||
|
<button class="tab" :class="{active: tab === 'customs'}" @click="tab = 'customs'">📋 报关单</button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<main class="container">
|
<main class="container">
|
||||||
@@ -109,7 +110,7 @@
|
|||||||
<thead>
|
<thead>
|
||||||
<tr style="background:#1a2332;text-align:left">
|
<tr style="background:#1a2332;text-align:left">
|
||||||
<th style="padding:10px 12px;border-bottom:1px solid #2a3441;color:#9aa0a6;font-size:13px">ID</th>
|
<th style="padding:10px 12px;border-bottom:1px solid #2a3441;color:#9aa0a6;font-size:13px">ID</th>
|
||||||
<th style="padding:10px 12px;border-bottom:1px solid #2a3441;color:#9aa0a6;font-size:13px">机型名称</th>
|
<th style="padding:10px 12px;border-bottom:1px solid #2a3441;color:#9aa0a6;font-size:13px">机型申请时间</th>
|
||||||
<th style="padding:10px 12px;border-bottom:1px solid #2a3441;color:#9aa0a6;font-size:13px">描述</th>
|
<th style="padding:10px 12px;border-bottom:1px solid #2a3441;color:#9aa0a6;font-size:13px">描述</th>
|
||||||
<th style="padding:10px 12px;border-bottom:1px solid #2a3441;color:#9aa0a6;font-size:13px">备注</th>
|
<th style="padding:10px 12px;border-bottom:1px solid #2a3441;color:#9aa0a6;font-size:13px">备注</th>
|
||||||
<th style="padding:10px 12px;border-bottom:1px solid #2a3441;color:#9aa0a6;font-size:13px;text-align:center">操作</th>
|
<th style="padding:10px 12px;border-bottom:1px solid #2a3441;color:#9aa0a6;font-size:13px;text-align:center">操作</th>
|
||||||
@@ -160,7 +161,7 @@
|
|||||||
<div style="margin-top:8px">
|
<div style="margin-top:8px">
|
||||||
<div style="display:flex;gap:8px;align-items:center;flex-wrap:wrap">
|
<div style="display:flex;gap:8px;align-items:center;flex-wrap:wrap">
|
||||||
<input type="text" v-model="newPoseForm[m.id + '_front']"
|
<input type="text" v-model="newPoseForm[m.id + '_front']"
|
||||||
placeholder="姿态名称(如:取料)"
|
placeholder="姿态申请时间(如:取料)"
|
||||||
style="flex:1;min-width:120px;padding:6px;border:1px solid #2a3441;border-radius:4px">
|
style="flex:1;min-width:120px;padding:6px;border:1px solid #2a3441;border-radius:4px">
|
||||||
<button class="btn btn-secondary btn-small" @click="addPose(m.id, 'front', newPoseForm[m.id + '_front'])">➕ 添加正面姿态(当前角度)</button>
|
<button class="btn btn-secondary btn-small" @click="addPose(m.id, 'front', newPoseForm[m.id + '_front'])">➕ 添加正面姿态(当前角度)</button>
|
||||||
</div>
|
</div>
|
||||||
@@ -207,7 +208,7 @@
|
|||||||
<div style="margin-top:8px">
|
<div style="margin-top:8px">
|
||||||
<div style="display:flex;gap:8px;align-items:center;flex-wrap:wrap">
|
<div style="display:flex;gap:8px;align-items:center;flex-wrap:wrap">
|
||||||
<input type="text" v-model="newPoseForm[m.id + '_back']"
|
<input type="text" v-model="newPoseForm[m.id + '_back']"
|
||||||
placeholder="姿态名称(如:放料)"
|
placeholder="姿态申请时间(如:放料)"
|
||||||
style="flex:1;min-width:120px;padding:6px;border:1px solid #2a3441;border-radius:4px">
|
style="flex:1;min-width:120px;padding:6px;border:1px solid #2a3441;border-radius:4px">
|
||||||
<button class="btn btn-secondary btn-small" @click="addPose(m.id, 'back', newPoseForm[m.id + '_back'])">➕ 添加背面姿态(当前角度)</button>
|
<button class="btn btn-secondary btn-small" @click="addPose(m.id, 'back', newPoseForm[m.id + '_back'])">➕ 添加背面姿态(当前角度)</button>
|
||||||
</div>
|
</div>
|
||||||
@@ -224,7 +225,7 @@
|
|||||||
<button class="btn-icon" @click="showAddModelModal = false">✕</button>
|
<button class="btn-icon" @click="showAddModelModal = false">✕</button>
|
||||||
</div>
|
</div>
|
||||||
<div class="form-group" style="margin-bottom:12px">
|
<div class="form-group" style="margin-bottom:12px">
|
||||||
<label>机型名称</label>
|
<label>机型申请时间</label>
|
||||||
<input type="text" v-model="newModelName" placeholder="例如:SMT-A" style="width:100%;padding:8px;border:1px solid #2a3441;border-radius:4px">
|
<input type="text" v-model="newModelName" placeholder="例如:SMT-A" style="width:100%;padding:8px;border:1px solid #2a3441;border-radius:4px">
|
||||||
</div>
|
</div>
|
||||||
<div class="form-group" style="margin-bottom:12px">
|
<div class="form-group" style="margin-bottom:12px">
|
||||||
@@ -433,13 +434,18 @@
|
|||||||
<h2>📷 二维码配置</h2>
|
<h2>📷 二维码配置</h2>
|
||||||
<p style="color:#9aa0a6;font-size:13px;margin-bottom:16px">配置机械臂姿态(6个关节角度),通过机械臂摄像头识别二维码并匹配机型。</p>
|
<p style="color:#9aa0a6;font-size:13px;margin-bottom:16px">配置机械臂姿态(6个关节角度),通过机械臂摄像头识别二维码并匹配机型。</p>
|
||||||
<!-- 机械臂摄像头画面 -->
|
<!-- 机械臂摄像头画面 -->
|
||||||
<div style="margin-bottom:16px">
|
<div style="margin-bottom:8px">
|
||||||
<div class="camera-preview" style="max-width:640px">
|
<div class="camera-preview" style="max-width:640px">
|
||||||
<img :src="armCameraUrl" @error="onArmPreviewError" style="width:100%;border-radius:8px">
|
<img :src="armCameraUrl" @error="onArmPreviewError" class="camera-img arm">
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div style="display:flex;justify-content:flex-end;margin-bottom:16px">
|
<div style="display:flex;justify-content:flex-end;margin-bottom:16px">
|
||||||
<input type="text" v-model="newQrName" placeholder="输入名称..." style="background:#0f1923;border:1px solid #2a3441;color:#fff;padding:8px 12px;border-radius:6px;margin-right:8px;width:180px">
|
<button class="btn btn-secondary btn-small" @click="captureArmSnapshot" :disabled="armSnapshotLoading">
|
||||||
|
📸 获取图片
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
<div style="display:flex;justify-content:flex-end;margin-bottom:16px">
|
||||||
|
<input type="text" v-model="newQrName" placeholder="输入申请时间..." style="background:#0f1923;border:1px solid #2a3441;color:#fff;padding:8px 12px;border-radius:6px;margin-right:8px;width:180px">
|
||||||
<button class="btn btn-primary" @click="addQrConfig()">➕ 添加</button>
|
<button class="btn btn-primary" @click="addQrConfig()">➕ 添加</button>
|
||||||
</div>
|
</div>
|
||||||
<div v-if="qrConfigs.length === 0" style="text-align:center;color:#9aa0a6;padding:40px">
|
<div v-if="qrConfigs.length === 0" style="text-align:center;color:#9aa0a6;padding:40px">
|
||||||
@@ -448,7 +454,7 @@
|
|||||||
<table v-else style="width:100%;border-collapse:collapse;margin-bottom:16px">
|
<table v-else style="width:100%;border-collapse:collapse;margin-bottom:16px">
|
||||||
<thead>
|
<thead>
|
||||||
<tr style="background:#1a2332;text-align:left">
|
<tr style="background:#1a2332;text-align:left">
|
||||||
<th style="padding:10px 8px;border-bottom:1px solid #2a3441;color:#9aa0a6;font-size:12px">名称</th>
|
<th style="padding:10px 8px;border-bottom:1px solid #2a3441;color:#9aa0a6;font-size:12px">申请时间</th>
|
||||||
<th style="padding:10px 4px;border-bottom:1px solid #2a3441;color:#9aa0a6;font-size:12px">J1</th>
|
<th style="padding:10px 4px;border-bottom:1px solid #2a3441;color:#9aa0a6;font-size:12px">J1</th>
|
||||||
<th style="padding:10px 4px;border-bottom:1px solid #2a3441;color:#9aa0a6;font-size:12px">J2</th>
|
<th style="padding:10px 4px;border-bottom:1px solid #2a3441;color:#9aa0a6;font-size:12px">J2</th>
|
||||||
<th style="padding:10px 4px;border-bottom:1px solid #2a3441;color:#9aa0a6;font-size:12px">J3</th>
|
<th style="padding:10px 4px;border-bottom:1px solid #2a3441;color:#9aa0a6;font-size:12px">J3</th>
|
||||||
@@ -476,6 +482,7 @@
|
|||||||
<button class="btn btn-secondary btn-small" @click="readQrAngles(q.id)" :disabled="!armConnected" title="读取当前机械臂关节角度">📋 加载姿态</button>
|
<button class="btn btn-secondary btn-small" @click="readQrAngles(q.id)" :disabled="!armConnected" title="读取当前机械臂关节角度">📋 加载姿态</button>
|
||||||
<button class="btn btn-primary btn-small" @click="applyQrAngles(q.id)" :disabled="!armConnected" style="margin-left:3px" title="将姿态应用到机械臂">🤖 应用姿态</button>
|
<button class="btn btn-primary btn-small" @click="applyQrAngles(q.id)" :disabled="!armConnected" style="margin-left:3px" title="将姿态应用到机械臂">🤖 应用姿态</button>
|
||||||
<button class="btn btn-success btn-small" @click="scanQrEntry(q.id)" :disabled="qrScanningId === q.id" style="margin-left:3px" title="扫描二维码">📷</button>
|
<button class="btn btn-success btn-small" @click="scanQrEntry(q.id)" :disabled="qrScanningId === q.id" style="margin-left:3px" title="扫描二维码">📷</button>
|
||||||
|
<button class="btn btn-secondary btn-small" @click="qrInputId = q.id; qrInputValue = q.qr_value || ''; showQrInputDialog = true" style="margin-left:3px" title="手动输入二维码值">✏️</button>
|
||||||
<button class="btn btn-danger btn-small" @click="deleteQrConfig(q.id)" style="margin-left:3px" title="删除">🗑️</button>
|
<button class="btn btn-danger btn-small" @click="deleteQrConfig(q.id)" style="margin-left:3px" title="删除">🗑️</button>
|
||||||
</td>
|
</td>
|
||||||
</tr>
|
</tr>
|
||||||
@@ -493,7 +500,7 @@
|
|||||||
</div>
|
</div>
|
||||||
<div v-else>
|
<div v-else>
|
||||||
<div class="camera-preview">
|
<div class="camera-preview">
|
||||||
<img :src="armCameraUrl" @error="onArmPreviewError">
|
<img :src="armCameraUrl" @error="onArmPreviewError" class="camera-img arm">
|
||||||
</div>
|
</div>
|
||||||
<div class="joints-panel">
|
<div class="joints-panel">
|
||||||
<h3>关节角度控制</h3>
|
<h3>关节角度控制</h3>
|
||||||
@@ -581,10 +588,136 @@
|
|||||||
</div>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
</div>
|
</div>
|
||||||
|
<!-- ========== 报关单 Tab ========== -->
|
||||||
|
<div v-if="tab === 'customs'">
|
||||||
|
<section class="card">
|
||||||
|
<div style="display:flex;justify-content:space-between;align-items:center">
|
||||||
|
<h2>📋 报关单列表</h2>
|
||||||
|
<button class="btn btn-secondary" @click="loadCustomsList" :disabled="customsLoading">
|
||||||
|
<span v-if="customsLoading">⏳</span><span v-else>🔄</span> 刷新
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
<p class="hint" style="margin-bottom:12px">选择报关单查看其中的机器列表,点击报关单 ID 展开机器信息</p>
|
||||||
|
|
||||||
|
<!-- 分页表格 -->
|
||||||
|
<div class="table-wrapper">
|
||||||
|
<table class="data-table">
|
||||||
|
<thead>
|
||||||
|
<tr>
|
||||||
|
<th style="width:60px">序号</th>
|
||||||
|
<th>报关单号</th>
|
||||||
|
<th>申请时间</th>
|
||||||
|
<th>状态</th>
|
||||||
|
<th>机器数</th>
|
||||||
|
<th style="width:80px">操作</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
<tr v-for="(item, idx) in customsPageData" :key="(item.customs && item.customs.id) || item.id || idx"
|
||||||
|
class="clickable-row"
|
||||||
|
:class="{ 'row-selected': selectedCustomsId === ((item.customs && item.customs.id) || item.id || item.customsId || item.customs_id) }"
|
||||||
|
@click="selectCustomsRow(item)">
|
||||||
|
<td>{% raw %}{{ (customsPage - 1) * customsPageSize + idx + 1 }}{% endraw %}</td>
|
||||||
|
<td><strong>{% raw %}{{ (item.customs && item.customs.customsCode) || item.orderCode || (item.customs && item.customs.id) || '-' }}{% endraw %}</strong></td>
|
||||||
|
<td>{% raw %}{{ item.orderCode || item.drawCode || '-' }}{% endraw %}</td>
|
||||||
|
<td><span class="badge" :class="((item.customs && item.customs.customsCode) ? 'badge-success' : 'badge-pending')">{% raw %}{{ (item.customs && item.customs.customsCode) ? '已报关' : '待报关' }}{% endraw %}</span></td>
|
||||||
|
<td>{% raw %}{{ (item.customs && item.customs.orderId) ? item.customs.orderId.split(',').length : '?' }}{% endraw %}</td>
|
||||||
|
<td>
|
||||||
|
<button class="btn btn-small btn-primary" @click.stop="selectCustomsRow(item)">
|
||||||
|
📦 查看机器
|
||||||
|
</button>
|
||||||
|
<button class="btn btn-small btn-success" style="margin-left:4px" @click.stop="startInspection(item)">
|
||||||
|
🔍 开始查验
|
||||||
|
</button>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
<tr v-if="!customsPageData.length && !customsLoading">
|
||||||
|
<td colspan="7" style="text-align:center;color:#8899aa;padding:24px">暂无报关单数据</td>
|
||||||
|
</tr>
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- 分页控件 -->
|
||||||
|
<div class="pagination" v-if="customsTotal > customsPageSize">
|
||||||
|
<button class="btn btn-small" :disabled="customsPage <= 1" @click="customsPage = customsPage - 1; loadCustomsList()">‹ 上一页</button>
|
||||||
|
<span style="margin:0 12px;color:#9aa0a6;font-size:13px">
|
||||||
|
第 {% raw %}{{ customsPage }}{% endraw %} / {% raw %}{{ customsTotalPages }}{% endraw %} 页(共 {% raw %}{{ customsTotal }}{% endraw %} 条)
|
||||||
|
</span>
|
||||||
|
<button class="btn btn-small" :disabled="customsPage >= customsTotalPages" @click="customsPage = customsPage + 1; loadCustomsList()">下一页 ›</button>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<!-- 机器列表(点击报关单行时展开) -->
|
||||||
|
<section class="card" v-if="customsMachines.length">
|
||||||
|
<div style="display:flex;justify-content:space-between;align-items:center">
|
||||||
|
<h2>📦 机器列表</h2>
|
||||||
|
<span style="color:#9aa0a6;font-size:13px">报关单: <strong style="color:#e0e0e0">{% raw %}{{ selectedCustomsName }}{% endraw %}</strong></span>
|
||||||
|
</div>
|
||||||
|
<p class="hint" style="margin-bottom:12px">共 <strong>{% raw %}{{ customsMachines.length }}{% endraw %}</strong> 台机器</p>
|
||||||
|
<div class="table-wrapper">
|
||||||
|
<table class="data-table">
|
||||||
|
<thead>
|
||||||
|
<tr>
|
||||||
|
<th style="width:50px">序号</th>
|
||||||
|
<th>物料编码</th>
|
||||||
|
<th>物料名称</th>
|
||||||
|
<th>规格</th>
|
||||||
|
<th>序列号</th>
|
||||||
|
<th>数量</th>
|
||||||
|
<th>查验数量</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
<tr v-for="(m, mi) in customsMachines" :key="mi">
|
||||||
|
<td>{% raw %}{{ mi + 1 }}{% endraw %}</td>
|
||||||
|
<td><strong>{% raw %}{{ m.inventoryCode || m.machineCode || '-' }}{% endraw %}</strong></td>
|
||||||
|
<td>{% raw %}{{ m.inventoryName || m.machineName || m.name || '-' }}{% endraw %}</td>
|
||||||
|
<td>{% raw %}{{ m.inventorySpecification || m.spec || '-' }}{% endraw %}</td>
|
||||||
|
<td style="font-family:monospace;color:#4fc3f7">{% raw %}{{ m.serialNumber || m.serialNumbers || m.qrValue || '-' }}{% endraw %}</td>
|
||||||
|
<td>{% raw %}{{ m.quantify || m.quantity || (m.quantify ? m.quantify : '?') }}{% endraw %}</td>
|
||||||
|
<td><span :class="(m.inspectionCount > 0) ? 'badge badge-success' : 'badge badge-pending'">{% raw %}{{ m.inspectionCount || 0 }}{% endraw %}</span></td>
|
||||||
|
</tr>
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
</div>
|
||||||
|
|
||||||
</main>
|
</main>
|
||||||
</div>
|
|
||||||
|
<!-- 手动输入二维码弹窗 -->
|
||||||
|
<div class="modal-overlay" v-if="showQrInputDialog">
|
||||||
|
<div class="modal" style="max-width:420px">
|
||||||
|
<h3>⌨️ 手动输入二维码</h3>
|
||||||
|
<p style="color:#9aa0a6;font-size:13px;margin:8px 0 16px">自动扫码未识别到二维码,请手动输入二维码内容:</p>
|
||||||
|
<input type="text" v-model="qrInputValue" placeholder="输入二维码内容..."
|
||||||
|
style="width:100%;background:#0f1923;border:1px solid #2a3441;color:#fff;padding:10px 12px;border-radius:6px;font-size:14px;box-sizing:border-box"
|
||||||
|
autofocus @keyup.enter="submitManualQr">
|
||||||
|
<div class="modal-actions" style="margin-top:16px">
|
||||||
|
<button class="btn btn-primary" @click="submitManualQr">确认</button>
|
||||||
|
<button class="btn" @click="showQrInputDialog = false; qrInputId = null; qrInputValue = ''">取消</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- 机械臂截图弹窗 -->
|
||||||
|
<div class="modal-overlay" v-if="showArmSnapshot && armSnapshotUrl" @click.self="showArmSnapshot = false">
|
||||||
|
<div class="modal" style="max-width:800px">
|
||||||
|
<div style="display:flex;justify-content:space-between;align-items:center;margin-bottom:12px">
|
||||||
|
<h3>📸 机械臂摄像头截图</h3>
|
||||||
|
<button class="btn btn-small" @click="showArmSnapshot = false" style="font-size:18px;background:none;border:none;color:#9aa0a6;cursor:pointer">✕</button>
|
||||||
|
</div>
|
||||||
|
<div style="background:#000;border-radius:8px;overflow:hidden">
|
||||||
|
<img :src="armSnapshotUrl" style="width:100%;display:block">
|
||||||
|
</div>
|
||||||
|
<div style="margin-top:8px;text-align:center;color:#9aa0a6;font-size:12px">
|
||||||
|
点击弹窗外关闭
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
<script src="/static/js/vue3.global.prod.js?v=20260526a"></script>
|
<script src="/static/js/vue3.global.prod.js?v=20260526a"></script>
|
||||||
<script src="/static/js/setting.js?v=20260529b"></script>
|
<script src="/static/js/setting.js?v=20260616f"></script>
|
||||||
</body>
|
</body>
|
||||||
</html>
|
</html>
|
||||||
|
|||||||
@@ -1,161 +0,0 @@
|
|||||||
"""
|
|
||||||
AGV 导航控制模块 - 通过 pymycobot 控制 AGV 运动
|
|
||||||
"""
|
|
||||||
import time
|
|
||||||
import logging
|
|
||||||
from typing import Tuple, Optional, List
|
|
||||||
|
|
||||||
logger = logging.getLogger(__name__)
|
|
||||||
|
|
||||||
# 尝试导入 pymycobot
|
|
||||||
try:
|
|
||||||
from pymycobot import MyAGVPro
|
|
||||||
MYCOBOT_AVAILABLE = True
|
|
||||||
except ImportError:
|
|
||||||
MYCOBOT_AVAILABLE = False
|
|
||||||
logger.warning("pymycobot 未安装,AGV 控制功能不可用")
|
|
||||||
|
|
||||||
|
|
||||||
class AGVController:
|
|
||||||
"""AGV 运动控制"""
|
|
||||||
|
|
||||||
def __init__(self, device: str = "/dev/agvpro_controller", baudrate: int = 1000000):
|
|
||||||
self.device = device
|
|
||||||
self.baudrate = baudrate
|
|
||||||
self._agv: Optional[MyAGVPro] = None
|
|
||||||
self._connected = False
|
|
||||||
|
|
||||||
def connect(self) -> bool:
|
|
||||||
"""连接 AGV"""
|
|
||||||
if not MYCOBOT_AVAILABLE:
|
|
||||||
logger.error("pymycobot 不可用")
|
|
||||||
return False
|
|
||||||
try:
|
|
||||||
self._agv = MyAGVPro(self.device, self.baudrate, debug=False)
|
|
||||||
# 检查是否上电
|
|
||||||
if self._agv.is_power_on():
|
|
||||||
self._connected = True
|
|
||||||
logger.info("AGV 连接成功")
|
|
||||||
return True
|
|
||||||
else:
|
|
||||||
logger.warning("AGV 未上电,尝试上电...")
|
|
||||||
self._agv.power_on()
|
|
||||||
time.sleep(2)
|
|
||||||
if self._agv.is_power_on():
|
|
||||||
self._connected = True
|
|
||||||
return True
|
|
||||||
return False
|
|
||||||
except Exception as e:
|
|
||||||
logger.error(f"AGV 连接失败: {e}")
|
|
||||||
return False
|
|
||||||
|
|
||||||
def is_connected(self) -> bool:
|
|
||||||
return self._connected and self._agv is not None
|
|
||||||
|
|
||||||
def move_forward(self, speed: float = 0.5, duration: float = None):
|
|
||||||
"""前进"""
|
|
||||||
if not self.is_connected():
|
|
||||||
return
|
|
||||||
self._agv.move_forward(speed)
|
|
||||||
if duration:
|
|
||||||
time.sleep(duration)
|
|
||||||
self.stop()
|
|
||||||
|
|
||||||
def move_backward(self, speed: float = 0.5, duration: float = None):
|
|
||||||
"""后退"""
|
|
||||||
if not self.is_connected():
|
|
||||||
return
|
|
||||||
self._agv.move_backward(speed)
|
|
||||||
if duration:
|
|
||||||
time.sleep(duration)
|
|
||||||
self.stop()
|
|
||||||
|
|
||||||
def turn_left(self, speed: float = 0.5, duration: float = None):
|
|
||||||
"""左转"""
|
|
||||||
if not self.is_connected():
|
|
||||||
return
|
|
||||||
self._agv.turn_left(speed)
|
|
||||||
if duration:
|
|
||||||
time.sleep(duration)
|
|
||||||
self.stop()
|
|
||||||
|
|
||||||
def turn_right(self, speed: float = 0.5, duration: float = None):
|
|
||||||
"""右转"""
|
|
||||||
if not self.is_connected():
|
|
||||||
return
|
|
||||||
self._agv.turn_right(speed)
|
|
||||||
if duration:
|
|
||||||
time.sleep(duration)
|
|
||||||
self.stop()
|
|
||||||
|
|
||||||
def move_left_lateral(self, speed: float = 0.5, duration: float = None):
|
|
||||||
"""向左横向移动"""
|
|
||||||
if not self.is_connected():
|
|
||||||
return
|
|
||||||
self._agv.move_left_lateral(speed)
|
|
||||||
if duration:
|
|
||||||
time.sleep(duration)
|
|
||||||
self.stop()
|
|
||||||
|
|
||||||
def move_right_lateral(self, speed: float = 0.5, duration: float = None):
|
|
||||||
"""向右横向移动"""
|
|
||||||
if not self.is_connected():
|
|
||||||
return
|
|
||||||
self._agv.move_right_lateral(speed)
|
|
||||||
if duration:
|
|
||||||
time.sleep(duration)
|
|
||||||
self.stop()
|
|
||||||
|
|
||||||
def stop(self):
|
|
||||||
"""停止"""
|
|
||||||
if self.is_connected():
|
|
||||||
self._agv.stop()
|
|
||||||
|
|
||||||
def get_position(self) -> Optional[List[float]]:
|
|
||||||
"""获取 AGV 当前位置 [x, y, rz]"""
|
|
||||||
if not self.is_connected():
|
|
||||||
return None
|
|
||||||
try:
|
|
||||||
# 启用自动报告以获取位置
|
|
||||||
self._agv.set_auto_report_state(1)
|
|
||||||
time.sleep(0.5)
|
|
||||||
msg = self._agv.get_auto_report_message()
|
|
||||||
if msg and len(msg) >= 3:
|
|
||||||
return [msg[0], msg[1], msg[2]]
|
|
||||||
except Exception as e:
|
|
||||||
logger.error(f"获取 AGV 位置失败: {e}")
|
|
||||||
return None
|
|
||||||
|
|
||||||
def go_to_point(self, x: float, y: float, rz: float = None, speed: float = 0.5) -> bool:
|
|
||||||
"""移动到目标点(简单的方向控制实现)"""
|
|
||||||
# 注意:AGV Pro 的 pymycobot 没有直接 goto API
|
|
||||||
# 需要 ROS2 SLAM 导航支持,此处提供基础运动接口
|
|
||||||
# 实际导航需要结合地图和路径规划
|
|
||||||
logger.warning("go_to_point 需要 ROS2 导航支持,当前仅记录目标")
|
|
||||||
return True
|
|
||||||
|
|
||||||
def get_battery(self) -> Optional[float]:
|
|
||||||
"""获取电池电压"""
|
|
||||||
if not self.is_connected():
|
|
||||||
return None
|
|
||||||
try:
|
|
||||||
self._agv.set_auto_report_state(1)
|
|
||||||
msg = self._agv.get_auto_report_message()
|
|
||||||
if msg and len(msg) > 5:
|
|
||||||
return msg[5] # 电池电压
|
|
||||||
except:
|
|
||||||
pass
|
|
||||||
return None
|
|
||||||
|
|
||||||
def disconnect(self):
|
|
||||||
if self._agv:
|
|
||||||
self.stop()
|
|
||||||
self._agv = None
|
|
||||||
self._connected = False
|
|
||||||
|
|
||||||
def __enter__(self):
|
|
||||||
self.connect()
|
|
||||||
return self
|
|
||||||
|
|
||||||
def __exit__(self, *args):
|
|
||||||
self.disconnect()
|
|
||||||
@@ -76,7 +76,7 @@ class AGVController:
|
|||||||
if rc != 0:
|
if rc != 0:
|
||||||
logger.warning(f"发布 cmd_vel 失败: {err}")
|
logger.warning(f"发布 cmd_vel 失败: {err}")
|
||||||
|
|
||||||
def move_forward(self, speed: float = 0.5, duration: float = None):
|
def move_forward(self, speed: float = 1.0, duration: float = None):
|
||||||
"""前进"""
|
"""前进"""
|
||||||
if not self.is_connected():
|
if not self.is_connected():
|
||||||
return
|
return
|
||||||
@@ -85,7 +85,7 @@ class AGVController:
|
|||||||
time.sleep(duration)
|
time.sleep(duration)
|
||||||
self.stop()
|
self.stop()
|
||||||
|
|
||||||
def move_backward(self, speed: float = 0.5, duration: float = None):
|
def move_backward(self, speed: float = 1.0, duration: float = None):
|
||||||
"""后退"""
|
"""后退"""
|
||||||
if not self.is_connected():
|
if not self.is_connected():
|
||||||
return
|
return
|
||||||
@@ -94,7 +94,7 @@ class AGVController:
|
|||||||
time.sleep(duration)
|
time.sleep(duration)
|
||||||
self.stop()
|
self.stop()
|
||||||
|
|
||||||
def turn_left(self, speed: float = 0.5, duration: float = None):
|
def turn_left(self, speed: float = 1.0, duration: float = None):
|
||||||
"""左转"""
|
"""左转"""
|
||||||
if not self.is_connected():
|
if not self.is_connected():
|
||||||
return
|
return
|
||||||
@@ -103,7 +103,7 @@ class AGVController:
|
|||||||
time.sleep(duration)
|
time.sleep(duration)
|
||||||
self.stop()
|
self.stop()
|
||||||
|
|
||||||
def turn_right(self, speed: float = 0.5, duration: float = None):
|
def turn_right(self, speed: float = 1.0, duration: float = None):
|
||||||
"""右转"""
|
"""右转"""
|
||||||
if not self.is_connected():
|
if not self.is_connected():
|
||||||
return
|
return
|
||||||
@@ -112,7 +112,7 @@ class AGVController:
|
|||||||
time.sleep(duration)
|
time.sleep(duration)
|
||||||
self.stop()
|
self.stop()
|
||||||
|
|
||||||
def move_left_lateral(self, speed: float = 0.5, duration: float = None):
|
def move_left_lateral(self, speed: float = 1.0, duration: float = None):
|
||||||
"""向左横向移动"""
|
"""向左横向移动"""
|
||||||
if not self.is_connected():
|
if not self.is_connected():
|
||||||
return
|
return
|
||||||
@@ -121,7 +121,7 @@ class AGVController:
|
|||||||
time.sleep(duration)
|
time.sleep(duration)
|
||||||
self.stop()
|
self.stop()
|
||||||
|
|
||||||
def move_right_lateral(self, speed: float = 0.5, duration: float = None):
|
def move_right_lateral(self, speed: float = 1.0, duration: float = None):
|
||||||
"""向右横向移动"""
|
"""向右横向移动"""
|
||||||
if not self.is_connected():
|
if not self.is_connected():
|
||||||
return
|
return
|
||||||
@@ -176,7 +176,7 @@ class AGVController:
|
|||||||
return self._position
|
return self._position
|
||||||
return None
|
return None
|
||||||
|
|
||||||
def go_to_point(self, x: float, y: float, rz: float = None, speed: float = 0.5) -> bool:
|
def go_to_point(self, x: float, y: float, rz: float = None, speed: float = 1.0) -> bool:
|
||||||
"""移动到目标点(需要 ROS2 导航栈)"""
|
"""移动到目标点(需要 ROS2 导航栈)"""
|
||||||
logger.warning("go_to_point 需要 ROS2 Nav2 支持,当前仅记录目标")
|
logger.warning("go_to_point 需要 ROS2 Nav2 支持,当前仅记录目标")
|
||||||
return True
|
return True
|
||||||
|
|||||||
+27
-14
@@ -51,6 +51,10 @@ class ArmClient:
|
|||||||
self._sock.close()
|
self._sock.close()
|
||||||
self._sock = None
|
self._sock = None
|
||||||
|
|
||||||
|
def is_connected(self) -> bool:
|
||||||
|
"""返回当前 TCP 连接是否仍由客户端持有。"""
|
||||||
|
return self._sock is not None
|
||||||
|
|
||||||
def reconnect(self) -> bool:
|
def reconnect(self) -> bool:
|
||||||
self.close()
|
self.close()
|
||||||
time.sleep(1)
|
time.sleep(1)
|
||||||
@@ -61,14 +65,18 @@ class ArmClient:
|
|||||||
def get_angles(self) -> Tuple[bool, List[float]]:
|
def get_angles(self) -> Tuple[bool, List[float]]:
|
||||||
"""获取所有关节角度"""
|
"""获取所有关节角度"""
|
||||||
ok, resp = self.send_command("get_angles()")
|
ok, resp = self.send_command("get_angles()")
|
||||||
if ok and resp.startswith("get_angles:["):
|
if ok:
|
||||||
try:
|
try:
|
||||||
# get_angles:[0.174, 0.520, ...] → list
|
# 兼容 "get_angles:[-260.2,...]" 和 "[-260.2,...]" 两种格式
|
||||||
nums = resp.split("[")[1].split("]")[0]
|
text = resp.split(":", 1)[-1] if ":" in resp else resp
|
||||||
angles = [float(x) for x in nums.split(",")]
|
text = text.strip()
|
||||||
return True, angles
|
if text.startswith("[") and text.endswith("]"):
|
||||||
|
nums = text[1:-1].split(",")
|
||||||
|
angles = [float(x) for x in nums]
|
||||||
|
if len(angles) == 6:
|
||||||
|
return True, angles
|
||||||
except:
|
except:
|
||||||
return False, []
|
pass
|
||||||
return False, []
|
return False, []
|
||||||
|
|
||||||
def set_angles(self, angles: List[float], speed: int = 500) -> bool:
|
def set_angles(self, angles: List[float], speed: int = 500) -> bool:
|
||||||
@@ -94,13 +102,17 @@ class ArmClient:
|
|||||||
def get_coords(self) -> Tuple[bool, List[float]]:
|
def get_coords(self) -> Tuple[bool, List[float]]:
|
||||||
"""获取当前坐标和姿态 [x, y, z, rx, ry, rz]"""
|
"""获取当前坐标和姿态 [x, y, z, rx, ry, rz]"""
|
||||||
ok, resp = self.send_command("get_coords()")
|
ok, resp = self.send_command("get_coords()")
|
||||||
if ok and "get_coords:" in resp:
|
if ok:
|
||||||
try:
|
try:
|
||||||
nums = resp.split("[")[1].split("]")[0]
|
text = resp.split(":", 1)[-1] if ":" in resp else resp
|
||||||
coords = [float(x) for x in nums.split(",")]
|
text = text.strip()
|
||||||
return True, coords
|
if text.startswith("[") and text.endswith("]"):
|
||||||
|
nums = text[1:-1].split(",")
|
||||||
|
coords = [float(x) for x in nums]
|
||||||
|
if len(coords) == 6:
|
||||||
|
return True, coords
|
||||||
except:
|
except:
|
||||||
return False, []
|
pass
|
||||||
return False, []
|
return False, []
|
||||||
|
|
||||||
def set_coords(self, coords: List[float], speed: int = 500) -> bool:
|
def set_coords(self, coords: List[float], speed: int = 500) -> bool:
|
||||||
@@ -132,19 +144,20 @@ class ArmClient:
|
|||||||
def state_check(self) -> bool:
|
def state_check(self) -> bool:
|
||||||
"""检查机械臂状态是否正常"""
|
"""检查机械臂状态是否正常"""
|
||||||
ok, resp = self.send_command("state_check()")
|
ok, resp = self.send_command("state_check()")
|
||||||
return ok and resp == "state_check:1"
|
# 兼容 "state_check:1" 和 "1" 两种格式
|
||||||
|
return ok and resp.strip().lstrip("state_check:") == "1"
|
||||||
|
|
||||||
def check_running(self) -> bool:
|
def check_running(self) -> bool:
|
||||||
"""检查机械臂是否在运行"""
|
"""检查机械臂是否在运行"""
|
||||||
ok, resp = self.send_command("check_running()")
|
ok, resp = self.send_command("check_running()")
|
||||||
return ok and resp == "check_running:1"
|
return ok and resp.strip().lstrip("check_running:") == "1"
|
||||||
|
|
||||||
def wait_done(self, timeout: float = 30) -> bool:
|
def wait_done(self, timeout: float = 30) -> bool:
|
||||||
"""等待上一条命令执行完成"""
|
"""等待上一条命令执行完成"""
|
||||||
start = time.time()
|
start = time.time()
|
||||||
while time.time() - start < timeout:
|
while time.time() - start < timeout:
|
||||||
ok, resp = self.send_command("check_running()")
|
ok, resp = self.send_command("check_running()")
|
||||||
if ok and resp == "check_running:0":
|
if ok and resp.strip().lstrip("check_running:") == "0":
|
||||||
return True
|
return True
|
||||||
time.sleep(0.5)
|
time.sleep(0.5)
|
||||||
return False
|
return False
|
||||||
|
|||||||
@@ -1,92 +0,0 @@
|
|||||||
"""
|
|
||||||
配置文件 - 所有可配置参数集中管理
|
|
||||||
"""
|
|
||||||
import os
|
|
||||||
|
|
||||||
# 基础路径(部署后对应 ~/work/agv_app)
|
|
||||||
BASE_DIR = os.path.dirname(os.path.abspath(__file__))
|
|
||||||
|
|
||||||
# ========== 网络配置(集中管理所有 IP 地址 — 修改此处即可全局生效)==========
|
|
||||||
AGV_HOST = "192.168.60.177"
|
|
||||||
ARM_HOST = "192.168.60.88"
|
|
||||||
|
|
||||||
# ========== AGV 参数 ==========
|
|
||||||
AGV_CONFIG = {
|
|
||||||
"device": "/dev/agvpro_controller",
|
|
||||||
"baudrate": 10000000,
|
|
||||||
"move_speed": 0.5,
|
|
||||||
"turn_speed": 0.5,
|
|
||||||
}
|
|
||||||
|
|
||||||
# ========== 机械臂 TCP 客户端 ==========
|
|
||||||
ARM_CONFIG = {
|
|
||||||
"host": ARM_HOST,
|
|
||||||
"port": 5002,
|
|
||||||
"timeout": 8,
|
|
||||||
"retry_times": 3,
|
|
||||||
"retry_interval": 1,
|
|
||||||
}
|
|
||||||
|
|
||||||
# ========== 地图 ==========
|
|
||||||
MAP_CONFIG = {
|
|
||||||
"map_dir": "/home/elephant/agv_pro_ros2/src/agv_pro_navigation2/map/",
|
|
||||||
"map_file": "map.yaml",
|
|
||||||
}
|
|
||||||
|
|
||||||
# ========== 摄像头 ==========
|
|
||||||
CAMERA_CONFIG = {
|
|
||||||
"device_index": 4, # AGV 摄像头 video4(标准彩色摄像头,V4L2后端)
|
|
||||||
"backend": "v4l2", # 使用 V4L2 后端获取标准彩色格式(640x480)
|
|
||||||
"qr_detect_interval": 0.5,
|
|
||||||
"capture_delay": 0.5,
|
|
||||||
}
|
|
||||||
|
|
||||||
# ========== 机械臂摄像头流 ==========
|
|
||||||
ARM_CAMERA_CONFIG = {
|
|
||||||
"url": f"http://{ARM_HOST}:5003/api/camera/preview",
|
|
||||||
"snapshot_url": f"http://{ARM_HOST}:5003/api/camera/snapshot",
|
|
||||||
}
|
|
||||||
|
|
||||||
# ========== HTTP 上传 ==========
|
|
||||||
UPLOAD_CONFIG = {
|
|
||||||
"url": "https://ts.zhijian168.com/prod-api/file/uploadImage",
|
|
||||||
"timeout": 30,
|
|
||||||
"max_retries": 3,
|
|
||||||
}
|
|
||||||
|
|
||||||
# ========== Flask 服务器 ==========
|
|
||||||
SERVER_CONFIG = {
|
|
||||||
"host": "0.0.0.0",
|
|
||||||
"port": 5000,
|
|
||||||
"secret_key": "agv630_secret_key_2024",
|
|
||||||
"debug": False,
|
|
||||||
}
|
|
||||||
|
|
||||||
# ========== 任务配置存储路径 ==========
|
|
||||||
DATA_DIR = os.path.join(BASE_DIR, "data")
|
|
||||||
os.makedirs(DATA_DIR, exist_ok=True)
|
|
||||||
|
|
||||||
# ========== 关节角度范围限制 ==========
|
|
||||||
JOINT_LIMITS = {
|
|
||||||
"J1": (-180.0, 180.0),
|
|
||||||
"J2": (-270.0, 90.0),
|
|
||||||
"J3": (-150.0, 150.0),
|
|
||||||
"J4": (-260.0, 80.0),
|
|
||||||
"J5": (-168.0, 168.0),
|
|
||||||
"J6": (-174.0, 174.0),
|
|
||||||
}
|
|
||||||
|
|
||||||
# ========== 机械臂默认速度 ==========
|
|
||||||
DEFAULT_ARM_SPEED = 500
|
|
||||||
|
|
||||||
# ========== 状态定义 ==========
|
|
||||||
class State:
|
|
||||||
SETTING = "setting"
|
|
||||||
RUNNING = "running"
|
|
||||||
PAUSED = "paused"
|
|
||||||
IDLE = "idle"
|
|
||||||
|
|
||||||
class PhotoType:
|
|
||||||
FRONT = "front"
|
|
||||||
BACK = "back"
|
|
||||||
NAMEPLATE = "nameplate"
|
|
||||||
@@ -1,663 +0,0 @@
|
|||||||
"""
|
|
||||||
地图导航模块 - A* 路径规划 + Pure Pursuit 路径跟踪
|
|
||||||
在已知地图上规划路径,控制 AGV 自动导航到目标坐标
|
|
||||||
|
|
||||||
依赖:numpy, cv2, Pillow(均已安装在 AGV 上)
|
|
||||||
不依赖:激光雷达、SLAM、Nav2
|
|
||||||
"""
|
|
||||||
|
|
||||||
import os
|
|
||||||
import math
|
|
||||||
import heapq
|
|
||||||
import time
|
|
||||||
import logging
|
|
||||||
import threading
|
|
||||||
import subprocess
|
|
||||||
import numpy as np
|
|
||||||
import cv2
|
|
||||||
import yaml
|
|
||||||
from typing import List, Tuple, Optional, Dict
|
|
||||||
from enum import Enum
|
|
||||||
|
|
||||||
logger = logging.getLogger(__name__)
|
|
||||||
|
|
||||||
# ROS2 环境设置(与 agv_controller_ros2.py 保持一致)
|
|
||||||
ROS2_SETUP_CMD = "export ROS_DOMAIN_ID=1 && source /opt/ros/humble/setup.bash && source ~/agv_pro_ros2/install/setup.bash"
|
|
||||||
|
|
||||||
|
|
||||||
# ========== 坐标转换 ==========
|
|
||||||
|
|
||||||
class CoordTransformer:
|
|
||||||
"""地图世界坐标 ↔ 栅格坐标 双向转换"""
|
|
||||||
|
|
||||||
def __init__(self, resolution: float, origin: List[float], width: int, height: int):
|
|
||||||
"""
|
|
||||||
Args:
|
|
||||||
resolution: 地图分辨率(米/像素)
|
|
||||||
origin: [x, y, yaw] 地图原点在世界坐标系中的位置
|
|
||||||
width: 地图宽度(像素)
|
|
||||||
height: 地图高度(像素)
|
|
||||||
"""
|
|
||||||
self.resolution = resolution
|
|
||||||
self.origin = origin # [ox, oy, oyaw]
|
|
||||||
self.width = width
|
|
||||||
self.height = height
|
|
||||||
|
|
||||||
def world_to_grid(self, wx: float, wy: float) -> Tuple[int, int]:
|
|
||||||
"""世界坐标 → 栅格坐标 [col, row]"""
|
|
||||||
col = int((wx - self.origin[0]) / self.resolution)
|
|
||||||
row = int((wy - self.origin[1]) / self.resolution)
|
|
||||||
# ROS 地图 row=0 对应图像最上方(y 最大值),需要翻转
|
|
||||||
row = self.height - 1 - row
|
|
||||||
return (col, row)
|
|
||||||
|
|
||||||
def grid_to_world(self, col: int, row: int) -> Tuple[float, float]:
|
|
||||||
"""栅格坐标 [col, row] → 世界坐标 [x, y]"""
|
|
||||||
# 翻转 row
|
|
||||||
actual_row = self.height - 1 - row
|
|
||||||
wx = col * self.resolution + self.origin[0]
|
|
||||||
wy = actual_row * self.resolution + self.origin[1]
|
|
||||||
return (wx, wy)
|
|
||||||
|
|
||||||
def world_to_grid_center(self, wx: float, wy: float) -> Tuple[float, float]:
|
|
||||||
"""世界坐标 → 栅格中心的世界坐标(对齐到栅格)"""
|
|
||||||
col, row = self.world_to_grid(wx, wy)
|
|
||||||
return self.grid_to_world(col, row)
|
|
||||||
|
|
||||||
|
|
||||||
# ========== A* 路径规划 ==========
|
|
||||||
|
|
||||||
class AStarPlanner:
|
|
||||||
"""A* 路径规划器,在栅格地图上规划最短路径"""
|
|
||||||
|
|
||||||
# 8方向移动:右、左、下、上、右下、右上、左下、左上
|
|
||||||
DIRECTIONS = [
|
|
||||||
(1, 0), (-1, 0), (0, 1), (0, -1),
|
|
||||||
(1, 1), (1, -1), (-1, 1), (-1, -1)
|
|
||||||
]
|
|
||||||
# 对角线移动的代价乘数(sqrt(2))
|
|
||||||
DIR_COSTS = [1.0, 1.0, 1.0, 1.0, 1.414, 1.414, 1.414, 1.414]
|
|
||||||
|
|
||||||
def __init__(self, occupancy_grid: np.ndarray, inflation_radius: int = 3):
|
|
||||||
"""
|
|
||||||
Args:
|
|
||||||
occupancy_grid: 栅格地图,0=空闲,255=障碍物
|
|
||||||
inflation_radius: 障碍物膨胀半径(像素),AGV 有一定体积不能贴墙走
|
|
||||||
"""
|
|
||||||
self.grid = occupancy_grid
|
|
||||||
self.height, self.width = occupancy_grid.shape
|
|
||||||
self.inflated = self._inflate(inflation_radius)
|
|
||||||
|
|
||||||
def _inflate(self, radius: int) -> np.ndarray:
|
|
||||||
"""膨胀障碍物区域"""
|
|
||||||
if radius <= 0:
|
|
||||||
return self.grid.copy()
|
|
||||||
kernel = cv2.getStructuringElement(cv2.MORPH_ELLIPSE, (2 * radius + 1, 2 * radius + 1))
|
|
||||||
inflated = cv2.dilate(self.grid, kernel, iterations=1)
|
|
||||||
# 确保二值化
|
|
||||||
inflated = np.where(inflated > 50, 255, 0).astype(np.uint8)
|
|
||||||
return inflated
|
|
||||||
|
|
||||||
def plan(self, start: Tuple[int, int], goal: Tuple[int, int]) -> Optional[List[Tuple[int, int]]]:
|
|
||||||
"""
|
|
||||||
A* 路径规划
|
|
||||||
|
|
||||||
Args:
|
|
||||||
start: 起点栅格坐标 (col, row)
|
|
||||||
goal: 终点栅格坐标 (col, row)
|
|
||||||
|
|
||||||
Returns:
|
|
||||||
路径点列表 [(col, row), ...],包含起点和终点;无法规划时返回 None
|
|
||||||
"""
|
|
||||||
# 边界检查
|
|
||||||
if not self._is_valid(start) or not self._is_valid(goal):
|
|
||||||
logger.warning(f"起点或终点无效: start={start}, goal={goal}")
|
|
||||||
# 尝试找最近的可行点
|
|
||||||
start = self._find_nearest_free(start)
|
|
||||||
goal = self._find_nearest_free(goal)
|
|
||||||
if start is None or goal is None:
|
|
||||||
logger.error("无法找到有效的起点或终点")
|
|
||||||
return None
|
|
||||||
|
|
||||||
# 检查终点是否被障碍物包围
|
|
||||||
if self.inflated[goal[1], goal[0]] > 50:
|
|
||||||
goal = self._find_nearest_free(goal)
|
|
||||||
|
|
||||||
if goal is None:
|
|
||||||
logger.error("终点周围无可行区域")
|
|
||||||
return None
|
|
||||||
|
|
||||||
# A* 算法
|
|
||||||
open_set = []
|
|
||||||
heapq.heappush(open_set, (0.0, start))
|
|
||||||
came_from = {}
|
|
||||||
g_score = {start: 0.0}
|
|
||||||
closed_set = set()
|
|
||||||
|
|
||||||
while open_set:
|
|
||||||
_, current = heapq.heappop(open_set)
|
|
||||||
|
|
||||||
if current in closed_set:
|
|
||||||
continue
|
|
||||||
closed_set.add(current)
|
|
||||||
|
|
||||||
if current == goal:
|
|
||||||
# 回溯路径
|
|
||||||
path = []
|
|
||||||
while current in came_from:
|
|
||||||
path.append(current)
|
|
||||||
current = came_from[current]
|
|
||||||
path.append(start)
|
|
||||||
path.reverse()
|
|
||||||
return path
|
|
||||||
|
|
||||||
for i, (dx, dy) in enumerate(self.DIRECTIONS):
|
|
||||||
neighbor = (current[0] + dx, current[1] + dy)
|
|
||||||
|
|
||||||
if neighbor in closed_set:
|
|
||||||
continue
|
|
||||||
|
|
||||||
if not self._is_valid(neighbor):
|
|
||||||
continue
|
|
||||||
|
|
||||||
if self.inflated[neighbor[1], neighbor[0]] > 50:
|
|
||||||
continue
|
|
||||||
|
|
||||||
move_cost = self.DIR_COSTS[i]
|
|
||||||
tentative_g = g_score[current] + move_cost
|
|
||||||
|
|
||||||
if tentative_g < g_score.get(neighbor, float('inf')):
|
|
||||||
came_from[neighbor] = current
|
|
||||||
g_score[neighbor] = tentative_g
|
|
||||||
f_score = tentative_g + self._heuristic(neighbor, goal)
|
|
||||||
heapq.heappush(open_set, (f_score, neighbor))
|
|
||||||
|
|
||||||
logger.warning("A* 无法找到路径")
|
|
||||||
return None
|
|
||||||
|
|
||||||
def _heuristic(self, a: Tuple[int, int], b: Tuple[int, int]) -> float:
|
|
||||||
"""对角线距离启发式"""
|
|
||||||
dx = abs(a[0] - b[0])
|
|
||||||
dy = abs(a[1] - b[1])
|
|
||||||
return max(dx, dy) + (1.414 - 1) * min(dx, dy)
|
|
||||||
|
|
||||||
def _is_valid(self, pos: Tuple[int, int]) -> bool:
|
|
||||||
return 0 <= pos[0] < self.width and 0 <= pos[1] < self.height
|
|
||||||
|
|
||||||
def _find_nearest_free(self, pos: Tuple[int, int], max_dist: int = 10) -> Optional[Tuple[int, int]]:
|
|
||||||
"""在 pos 附近找最近的可行点"""
|
|
||||||
for r in range(1, max_dist + 1):
|
|
||||||
for dx in range(-r, r + 1):
|
|
||||||
for dy in range(-r, r + 1):
|
|
||||||
n = (pos[0] + dx, pos[1] + dy)
|
|
||||||
if self._is_valid(n) and self.inflated[n[1], n[0]] == 0:
|
|
||||||
return n
|
|
||||||
return None
|
|
||||||
|
|
||||||
|
|
||||||
# ========== 路径平滑 ==========
|
|
||||||
|
|
||||||
def smooth_path(grid: np.ndarray, path: List[Tuple[int, int]],
|
|
||||||
weight_data: float = 0.3, weight_smooth: float = 0.5,
|
|
||||||
tolerance: float = 1e-5, max_iter: int = 500) -> List[Tuple[int, int]]:
|
|
||||||
"""
|
|
||||||
路径平滑(梯度下降法)
|
|
||||||
在障碍物约束下让路径更平滑,减少不必要的转向
|
|
||||||
"""
|
|
||||||
if len(path) <= 2:
|
|
||||||
return path
|
|
||||||
|
|
||||||
height, width = grid.shape
|
|
||||||
new_path = [list(p) for p in path]
|
|
||||||
|
|
||||||
for iteration in range(max_iter):
|
|
||||||
change = 0.0
|
|
||||||
for i in range(1, len(new_path) - 1):
|
|
||||||
for j in range(2):
|
|
||||||
old_val = new_path[i][j]
|
|
||||||
# 数据项:趋向原始路径点
|
|
||||||
data_gradient = weight_data * (path[i][j] - new_path[i][j])
|
|
||||||
# 平滑项:趋向邻居中点
|
|
||||||
smooth_gradient = weight_smooth * (
|
|
||||||
new_path[i - 1][j] + new_path[i + 1][j] - 2 * new_path[i][j]
|
|
||||||
)
|
|
||||||
new_path[i][j] += data_gradient + smooth_gradient
|
|
||||||
|
|
||||||
# 边界约束
|
|
||||||
new_path[i][0] = max(0, min(width - 1, new_path[i][0]))
|
|
||||||
new_path[i][1] = max(0, min(height - 1, new_path[i][1]))
|
|
||||||
|
|
||||||
# 障碍物约束
|
|
||||||
col, row = int(round(new_path[i][0])), int(round(new_path[i][1]))
|
|
||||||
if 0 <= col < width and 0 <= row < height:
|
|
||||||
if grid[row, col] > 50:
|
|
||||||
new_path[i][j] = old_val # 回退
|
|
||||||
|
|
||||||
change += abs(new_path[i][j] - old_val)
|
|
||||||
|
|
||||||
if change < tolerance:
|
|
||||||
break
|
|
||||||
|
|
||||||
return [(int(round(p[0])), int(round(p[1]))) for p in new_path]
|
|
||||||
|
|
||||||
|
|
||||||
# ========== 路径降采样 ==========
|
|
||||||
|
|
||||||
def downsample_path(path: List[Tuple[int, int]], min_dist: int = 3) -> List[Tuple[int, int]]:
|
|
||||||
"""降采样路径,移除过近的点,减少 cmd_vel 发布频率"""
|
|
||||||
if len(path) <= 2:
|
|
||||||
return path
|
|
||||||
|
|
||||||
result = [path[0]]
|
|
||||||
for p in path[1:]:
|
|
||||||
last = result[-1]
|
|
||||||
dist = math.hypot(p[0] - last[0], p[1] - last[1])
|
|
||||||
if dist >= min_dist:
|
|
||||||
result.append(p)
|
|
||||||
# 确保终点包含在内
|
|
||||||
if result[-1] != path[-1]:
|
|
||||||
result.append(path[-1])
|
|
||||||
return result
|
|
||||||
|
|
||||||
|
|
||||||
# ========== Pure Pursuit 控制器 ==========
|
|
||||||
|
|
||||||
class PurePursuitController:
|
|
||||||
"""Pure Pursuit 路径跟踪控制器"""
|
|
||||||
|
|
||||||
def __init__(self, lookahead_distance: float = 0.3,
|
|
||||||
max_linear_speed: float = 0.4,
|
|
||||||
max_angular_speed: float = 0.8,
|
|
||||||
goal_tolerance: float = 0.15,
|
|
||||||
slow_down_distance: float = 0.5):
|
|
||||||
"""
|
|
||||||
Args:
|
|
||||||
lookahead_distance: 前视距离(米),越大转弯越平缓
|
|
||||||
max_linear_speed: 最大线速度 (m/s)
|
|
||||||
max_angular_speed: 最大角速度 (rad/s)
|
|
||||||
goal_tolerance: 到达目标容差(米)
|
|
||||||
slow_down_distance: 开始减速的距离(米)
|
|
||||||
"""
|
|
||||||
self.lookahead_distance = lookahead_distance
|
|
||||||
self.max_linear_speed = max_linear_speed
|
|
||||||
self.max_angular_speed = max_angular_speed
|
|
||||||
self.goal_tolerance = goal_tolerance
|
|
||||||
self.slow_down_distance = slow_down_distance
|
|
||||||
self.transformer: Optional[CoordTransformer] = None
|
|
||||||
|
|
||||||
def set_transformer(self, transformer: CoordTransformer):
|
|
||||||
self.transformer = transformer
|
|
||||||
|
|
||||||
def compute(self, current_pos: Tuple[float, float, float],
|
|
||||||
path_world: List[Tuple[float, float]]) -> Tuple[float, float, bool]:
|
|
||||||
"""
|
|
||||||
计算控制量
|
|
||||||
|
|
||||||
Args:
|
|
||||||
current_pos: (x, y, yaw) 当前世界坐标
|
|
||||||
path_world: 路径点列表 [(x, y), ...] 世界坐标
|
|
||||||
|
|
||||||
Returns:
|
|
||||||
(linear_x, angular_z, reached) 线速度、角速度、是否到达
|
|
||||||
"""
|
|
||||||
if not path_world:
|
|
||||||
return (0.0, 0.0, True)
|
|
||||||
|
|
||||||
x, y, yaw = current_pos
|
|
||||||
|
|
||||||
# 检查是否到达终点
|
|
||||||
goal = path_world[-1]
|
|
||||||
dist_to_goal = math.hypot(goal[0] - x, goal[1] - y)
|
|
||||||
if dist_to_goal < self.goal_tolerance:
|
|
||||||
return (0.0, 0.0, True)
|
|
||||||
|
|
||||||
# 找前视点(lookahead point)
|
|
||||||
lookahead_point = self._find_lookahead_point(x, y, path_world)
|
|
||||||
|
|
||||||
if lookahead_point is None:
|
|
||||||
# 已经越过最后一个点
|
|
||||||
return (0.0, 0.0, True)
|
|
||||||
|
|
||||||
lx, ly = lookahead_point
|
|
||||||
|
|
||||||
# 转换到机器人坐标系
|
|
||||||
dx = lx - x
|
|
||||||
dy = ly - y
|
|
||||||
|
|
||||||
# 旋转到机器人坐标系(x 轴朝前)
|
|
||||||
local_x = dx * math.cos(yaw) + dy * math.sin(yaw)
|
|
||||||
local_y = -dx * math.sin(yaw) + dy * math.cos(yaw)
|
|
||||||
|
|
||||||
# 弧长 = 角度 * 半径 → curvature = 2 * ly / L^2
|
|
||||||
L = math.hypot(local_x, local_y)
|
|
||||||
if L < 1e-6:
|
|
||||||
return (0.0, 0.0, True)
|
|
||||||
|
|
||||||
curvature = 2.0 * local_y / (L * L)
|
|
||||||
angular_z = curvature * self.max_linear_speed
|
|
||||||
|
|
||||||
# 根据距离调整速度
|
|
||||||
linear_x = self.max_linear_speed
|
|
||||||
if dist_to_goal < self.slow_down_distance:
|
|
||||||
ratio = max(0.15, dist_to_goal / self.slow_down_distance)
|
|
||||||
linear_x *= ratio
|
|
||||||
|
|
||||||
# 限制角速度
|
|
||||||
angular_z = max(-self.max_angular_speed, min(self.max_angular_speed, angular_z))
|
|
||||||
|
|
||||||
# 如果角度偏差太大,先原位转弯
|
|
||||||
angle_to_goal = math.atan2(ly - y, lx - x) - yaw
|
|
||||||
angle_to_goal = math.atan2(math.sin(angle_to_goal), math.cos(angle_to_goal))
|
|
||||||
|
|
||||||
if abs(angle_to_goal) > math.pi / 3:
|
|
||||||
# 角度偏差 > 60°,先原位转弯
|
|
||||||
linear_x = 0.0
|
|
||||||
angular_z = max(-self.max_angular_speed, min(self.max_angular_speed, angle_to_goal * 1.5))
|
|
||||||
|
|
||||||
return (linear_x, angular_z, False)
|
|
||||||
|
|
||||||
def _find_lookahead_point(self, x: float, y: float,
|
|
||||||
path: List[Tuple[float, float]]) -> Optional[Tuple[float, float]]:
|
|
||||||
"""沿路径找到前视距离处的点"""
|
|
||||||
for i in range(len(path) - 1, -1, -1):
|
|
||||||
dist = math.hypot(path[i][0] - x, path[i][1] - y)
|
|
||||||
if dist >= self.lookahead_distance:
|
|
||||||
return path[i]
|
|
||||||
# 如果所有点都在前视距离内,返回终点
|
|
||||||
return path[-1] if path else None
|
|
||||||
|
|
||||||
|
|
||||||
# ========== 导航器(核心模块) ==========
|
|
||||||
|
|
||||||
class NavStatus(Enum):
|
|
||||||
IDLE = "idle"
|
|
||||||
PLANNING = "planning"
|
|
||||||
NAVIGATING = "navigating"
|
|
||||||
REACHED = "reached"
|
|
||||||
FAILED = "failed"
|
|
||||||
CANCELLED = "cancelled"
|
|
||||||
|
|
||||||
|
|
||||||
class MapNavigator:
|
|
||||||
"""地图导航器 — 整合路径规划与路径跟踪"""
|
|
||||||
|
|
||||||
def __init__(self, map_yaml_path: str):
|
|
||||||
"""
|
|
||||||
Args:
|
|
||||||
map_yaml_path: map.yaml 文件的绝对路径
|
|
||||||
"""
|
|
||||||
self.map_yaml_path = map_yaml_path
|
|
||||||
self.transformer: Optional[CoordTransformer] = None
|
|
||||||
self.planner: Optional[AStarPlanner] = None
|
|
||||||
self.controller = PurePursuitController()
|
|
||||||
self.controller.set_transformer(self.transformer)
|
|
||||||
|
|
||||||
# 导航状态
|
|
||||||
self.status = NavStatus.IDLE
|
|
||||||
self._nav_thread: Optional[threading.Thread] = None
|
|
||||||
self._cancel_event = threading.Event()
|
|
||||||
|
|
||||||
# 当前路径(世界坐标)
|
|
||||||
self.path_world: List[Tuple[float, float]] = []
|
|
||||||
self.current_position = [0.0, 0.0, 0.0] # [x, y, yaw]
|
|
||||||
|
|
||||||
# 加载地图
|
|
||||||
self._load_map()
|
|
||||||
|
|
||||||
def _load_map(self):
|
|
||||||
"""加载地图 PGM + YAML"""
|
|
||||||
with open(self.map_yaml_path, 'r') as f:
|
|
||||||
meta = yaml.safe_load(f)
|
|
||||||
|
|
||||||
map_dir = os.path.dirname(self.map_yaml_path)
|
|
||||||
pgm_path = os.path.join(map_dir, meta['image'])
|
|
||||||
|
|
||||||
# 读取 PGM 灰度图
|
|
||||||
img = cv2.imread(pgm_path, cv2.IMREAD_GRAYSCALE)
|
|
||||||
if img is None:
|
|
||||||
raise FileNotFoundError(f"无法读取地图文件: {pgm_path}")
|
|
||||||
|
|
||||||
# ROS 地图:0=占用(障碍物),254=空闲,205=未知
|
|
||||||
# 转为二值:空闲=0,障碍物=255
|
|
||||||
self.occupancy = np.where(img <= 50, 255, 0).astype(np.uint8)
|
|
||||||
# 未知区域(205 附近)也视为障碍物
|
|
||||||
self.occupancy = np.where((img > 50) & (img < 250), 255, self.occupancy)
|
|
||||||
|
|
||||||
resolution = meta['resolution']
|
|
||||||
origin = meta.get('origin', [0, 0, 0])
|
|
||||||
height, width = img.shape
|
|
||||||
|
|
||||||
self.transformer = CoordTransformer(resolution, origin, width, height)
|
|
||||||
self.planner = AStarPlanner(self.occupancy, inflation_radius=3)
|
|
||||||
self.controller.set_transformer(self.transformer)
|
|
||||||
|
|
||||||
self._map_meta = meta
|
|
||||||
logger.info(f"地图加载完成: {width}x{height}, 分辨率 {resolution}m, 原点 {origin}")
|
|
||||||
|
|
||||||
def get_odom(self) -> List[float]:
|
|
||||||
"""从 /odom 话题获取当前位置 [x, y, yaw]"""
|
|
||||||
try:
|
|
||||||
cmd = f"timeout 5 ros2 topic echo /odom --once 2>/dev/null"
|
|
||||||
full_cmd = f"bash -c '{ROS2_SETUP_CMD} && {cmd}'"
|
|
||||||
result = subprocess.run(
|
|
||||||
full_cmd, shell=True, capture_output=True, text=True, timeout=6
|
|
||||||
)
|
|
||||||
if result.returncode == 0 and result.stdout:
|
|
||||||
yaml_str = result.stdout.split('---')[0]
|
|
||||||
data = yaml.safe_load(yaml_str)
|
|
||||||
if data:
|
|
||||||
pos = data.get("pose", {}).get("pose", {}).get("position", {})
|
|
||||||
x, y = pos.get("x", 0.0), pos.get("y", 0.0)
|
|
||||||
orient = data.get("pose", {}).get("pose", {}).get("orientation", {})
|
|
||||||
qz, qw = orient.get("z", 0.0), orient.get("w", 1.0)
|
|
||||||
yaw = math.atan2(2.0 * qw * qz, 1.0 - 2.0 * qz * qz)
|
|
||||||
self.current_position = [x, y, yaw]
|
|
||||||
return self.current_position
|
|
||||||
except Exception as e:
|
|
||||||
logger.debug(f"获取 odom 失败: {e}")
|
|
||||||
return self.current_position
|
|
||||||
|
|
||||||
def _publish_cmd_vel(self, linear_x: float, angular_z: float):
|
|
||||||
"""发布速度命令到 /cmd_vel"""
|
|
||||||
msg = (
|
|
||||||
f'{{"linear": {{"x": {linear_x:.4f}, "y": 0.0, "z": 0.0}}, '
|
|
||||||
f'"angular": {{"x": 0.0, "y": 0.0, "z": {angular_z:.4f}}}}}'
|
|
||||||
)
|
|
||||||
full_cmd = f"bash -c '{ROS2_SETUP_CMD} && ros2 topic pub /cmd_vel geometry_msgs/msg/Twist \"{msg}\" --once'"
|
|
||||||
try:
|
|
||||||
subprocess.run(full_cmd, shell=True, capture_output=True, text=True, timeout=3)
|
|
||||||
except subprocess.TimeoutExpired:
|
|
||||||
logger.warning("发布 cmd_vel 超时")
|
|
||||||
|
|
||||||
def _stop_cmd_vel(self):
|
|
||||||
"""发布停止命令"""
|
|
||||||
self._publish_cmd_vel(0.0, 0.0)
|
|
||||||
|
|
||||||
def plan_path(self, goal_x: float, goal_y: float,
|
|
||||||
start_x: float = None, start_y: float = None) -> bool:
|
|
||||||
"""
|
|
||||||
规划路径(不执行导航)
|
|
||||||
|
|
||||||
Args:
|
|
||||||
goal_x, goal_y: 目标世界坐标(米)
|
|
||||||
start_x, start_y: 起点世界坐标(米),默认使用当前 odom
|
|
||||||
|
|
||||||
Returns:
|
|
||||||
是否规划成功
|
|
||||||
"""
|
|
||||||
if self.transformer is None:
|
|
||||||
logger.error("地图未加载")
|
|
||||||
return False
|
|
||||||
|
|
||||||
# 获取起点
|
|
||||||
if start_x is None or start_y is None:
|
|
||||||
pos = self.get_odom()
|
|
||||||
start_x, start_y = pos[0], pos[1]
|
|
||||||
|
|
||||||
# 坐标转换
|
|
||||||
start_grid = self.transformer.world_to_grid(start_x, start_y)
|
|
||||||
goal_grid = self.transformer.world_to_grid(goal_x, goal_y)
|
|
||||||
|
|
||||||
logger.info(f"规划路径: 起点(世界){start_x:.2f},{start_y:.2f} → (栅格){start_grid}")
|
|
||||||
logger.info(f" 终点(世界){goal_x:.2f},{goal_y:.2f} → (栅格){goal_grid}")
|
|
||||||
|
|
||||||
# A* 规划
|
|
||||||
path_grid = self.planner.plan(start_grid, goal_grid)
|
|
||||||
if path_grid is None:
|
|
||||||
logger.warning("路径规划失败")
|
|
||||||
return False
|
|
||||||
|
|
||||||
# 路径平滑
|
|
||||||
path_grid = smooth_path(self.planner.inflated, path_grid)
|
|
||||||
|
|
||||||
# 降采样
|
|
||||||
path_grid = downsample_path(path_grid, min_dist=2)
|
|
||||||
|
|
||||||
# 转换为世界坐标
|
|
||||||
self.path_world = [self.transformer.grid_to_world(c, r) for c, r in path_grid]
|
|
||||||
|
|
||||||
logger.info(f"路径规划成功: {len(self.path_world)} 个路径点")
|
|
||||||
return True
|
|
||||||
|
|
||||||
def navigate_to(self, goal_x: float, goal_y, blocking: bool = False) -> bool:
|
|
||||||
"""
|
|
||||||
导航到目标点
|
|
||||||
|
|
||||||
Args:
|
|
||||||
goal_x, goal_y: 目标世界坐标(米)
|
|
||||||
blocking: 是否阻塞等待导航完成
|
|
||||||
|
|
||||||
Returns:
|
|
||||||
非阻塞模式下返回 True(表示已启动),阻塞模式下返回是否到达
|
|
||||||
"""
|
|
||||||
if self.status == NavStatus.NAVIGATING:
|
|
||||||
logger.warning("导航正在进行中,请先停止当前导航")
|
|
||||||
return False
|
|
||||||
|
|
||||||
# 规划路径
|
|
||||||
if not self.plan_path(goal_x, goal_y):
|
|
||||||
self.status = NavStatus.FAILED
|
|
||||||
return False
|
|
||||||
|
|
||||||
# 启动导航线程
|
|
||||||
self._cancel_event.clear()
|
|
||||||
self.status = NavStatus.NAVIGATING
|
|
||||||
self._nav_thread = threading.Thread(
|
|
||||||
target=self._navigate_thread,
|
|
||||||
args=(goal_x, goal_y),
|
|
||||||
daemon=True
|
|
||||||
)
|
|
||||||
self._nav_thread.start()
|
|
||||||
|
|
||||||
if blocking:
|
|
||||||
self._nav_thread.join()
|
|
||||||
return self.status == NavStatus.REACHED
|
|
||||||
|
|
||||||
return True
|
|
||||||
|
|
||||||
def _navigate_thread(self, goal_x: float, goal_y: float):
|
|
||||||
"""导航线程"""
|
|
||||||
logger.info(f"开始导航 → 目标 ({goal_x:.2f}, {goal_y:.2f})")
|
|
||||||
|
|
||||||
try:
|
|
||||||
# 转弯朝向第一个路径点
|
|
||||||
self._initial_turn()
|
|
||||||
|
|
||||||
# 跟踪路径
|
|
||||||
last_cmd_time = time.time()
|
|
||||||
cmd_interval = 0.2 # cmd_vel 发布间隔(秒)
|
|
||||||
|
|
||||||
while not self._cancel_event.is_set():
|
|
||||||
pos = self.get_odom()
|
|
||||||
x, y, yaw = pos
|
|
||||||
|
|
||||||
linear_x, angular_z, reached = self.controller.compute(
|
|
||||||
(x, y, yaw), self.path_world
|
|
||||||
)
|
|
||||||
|
|
||||||
if reached:
|
|
||||||
self._stop_cmd_vel()
|
|
||||||
self.status = NavStatus.REACHED
|
|
||||||
logger.info("✅ 已到达目标点")
|
|
||||||
return
|
|
||||||
|
|
||||||
# 控制发布频率
|
|
||||||
now = time.time()
|
|
||||||
if now - last_cmd_time >= cmd_interval:
|
|
||||||
self._publish_cmd_vel(linear_x, angular_z)
|
|
||||||
last_cmd_time = now
|
|
||||||
|
|
||||||
time.sleep(0.05) # 50ms 控制循环
|
|
||||||
|
|
||||||
# 被取消
|
|
||||||
self._stop_cmd_vel()
|
|
||||||
self.status = NavStatus.CANCELLED
|
|
||||||
logger.info("导航已取消")
|
|
||||||
|
|
||||||
except Exception as e:
|
|
||||||
self._stop_cmd_vel()
|
|
||||||
self.status = NavStatus.FAILED
|
|
||||||
logger.error(f"导航异常: {e}")
|
|
||||||
|
|
||||||
def _initial_turn(self):
|
|
||||||
"""导航开始前,先原地转向朝向第一个路径点"""
|
|
||||||
if len(self.path_world) < 2:
|
|
||||||
return
|
|
||||||
|
|
||||||
pos = self.get_odom()
|
|
||||||
x, y, yaw = pos
|
|
||||||
target = self.path_world[1] # 第一个路径点是当前位置,取第二个
|
|
||||||
|
|
||||||
angle_to_target = math.atan2(target[1] - y, target[0] - x) - yaw
|
|
||||||
angle_to_target = math.atan2(math.sin(angle_to_target), math.cos(angle_to_target))
|
|
||||||
|
|
||||||
if abs(angle_to_target) < 0.1: # < 6°,不需要转弯
|
|
||||||
return
|
|
||||||
|
|
||||||
logger.info(f"初始转向: {math.degrees(angle_to_target):.1f}°")
|
|
||||||
|
|
||||||
# 分段旋转(避免一步到位导致超调)
|
|
||||||
steps = max(3, int(abs(angle_to_target) / 0.2))
|
|
||||||
step_angle = angle_to_target / steps
|
|
||||||
step_time = abs(step_angle) / self.controller.max_angular_speed + 0.1
|
|
||||||
|
|
||||||
for _ in range(steps):
|
|
||||||
if self._cancel_event.is_set():
|
|
||||||
return
|
|
||||||
angular = max(-self.controller.max_angular_speed,
|
|
||||||
min(self.controller.max_angular_speed, step_angle * 2))
|
|
||||||
self._publish_cmd_vel(0.0, angular)
|
|
||||||
time.sleep(step_time)
|
|
||||||
|
|
||||||
self._stop_cmd_vel()
|
|
||||||
time.sleep(0.2) # 稳定后继续
|
|
||||||
|
|
||||||
def stop(self):
|
|
||||||
"""停止当前导航"""
|
|
||||||
if self.status == NavStatus.NAVIGATING:
|
|
||||||
self._cancel_event.set()
|
|
||||||
self._stop_cmd_vel()
|
|
||||||
if self._nav_thread and self._nav_thread.is_alive():
|
|
||||||
self._nav_thread.join(timeout=3)
|
|
||||||
self.status = NavStatus.CANCELLED
|
|
||||||
|
|
||||||
def get_status(self) -> dict:
|
|
||||||
"""获取导航状态"""
|
|
||||||
pos = self.get_odom()
|
|
||||||
return {
|
|
||||||
"status": self.status.value,
|
|
||||||
"current_position": pos,
|
|
||||||
"path_length": len(self.path_world),
|
|
||||||
"path": self.path_world if self.status in (NavStatus.NAVIGATING, NavStatus.REACHED) else []
|
|
||||||
}
|
|
||||||
|
|
||||||
def get_path_preview(self, goal_x: float, goal_y: float) -> Optional[List[Tuple[float, float]]]:
|
|
||||||
"""
|
|
||||||
预览路径(仅规划不执行),用于前端可视化
|
|
||||||
|
|
||||||
Returns:
|
|
||||||
世界坐标路径列表,或 None(规划失败)
|
|
||||||
"""
|
|
||||||
if self.plan_path(goal_x, goal_y):
|
|
||||||
return self.path_world
|
|
||||||
return None
|
|
||||||
@@ -28,10 +28,10 @@ from utils.nav2_navigator import Nav2Navigator, Nav2Status
|
|||||||
logger = logging.getLogger(__name__)
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
ROS2_SETUP_CMD = "source /opt/ros/humble/setup.bash && source ~/agv_pro_ros2/install/setup.bash"
|
ROS2_SETUP_CMD = "source /opt/ros/humble/setup.bash && source ~/agv_pro_ros2/install/setup.bash"
|
||||||
from config import ARM_CAMERA_CONFIG
|
from config import ARM_CAMERA_CONFIG, UPLOAD_CONFIG, ZHIJIAN_AUTH_TOKEN
|
||||||
ARM_CAMERA_SNAPSHOT = ARM_CAMERA_CONFIG["snapshot_url"]
|
ARM_CAMERA_SNAPSHOT = ARM_CAMERA_CONFIG["snapshot_url"]
|
||||||
PHOTOS_DIR = "/home/elephant/photos"
|
PHOTOS_DIR = "/home/elephant/photos"
|
||||||
UPLOAD_URL = "https://ts.zhijian168.com/prod-api/file/uploadImage"
|
# UPLOAD_CONFIG["url"] 随环境切换动态变化,每次使用时直接读取
|
||||||
|
|
||||||
# 二维码扫描重试参数
|
# 二维码扫描重试参数
|
||||||
QR_SCAN_TIMEOUT = 5 # 单次扫描超时
|
QR_SCAN_TIMEOUT = 5 # 单次扫描超时
|
||||||
@@ -101,11 +101,10 @@ class MissionExecutorV3:
|
|||||||
self._nav = Nav2Navigator()
|
self._nav = Nav2Navigator()
|
||||||
|
|
||||||
# 速度控制(默认值,可在 execute_mission 时覆写)
|
# 速度控制(默认值,可在 execute_mission 时覆写)
|
||||||
self.arm_speed = 500
|
self.arm_speed = 1000
|
||||||
self.agv_speed = 0.5
|
self.agv_speed = 1.0
|
||||||
|
|
||||||
# 照片上传序号计数器(连续递增,从1开始)
|
# 照片上传序号计数器(连续递增,从1开始)
|
||||||
self.next_upload_index = 1
|
|
||||||
|
|
||||||
# ==================== 连接 ====================
|
# ==================== 连接 ====================
|
||||||
|
|
||||||
@@ -239,7 +238,6 @@ class MissionExecutorV3:
|
|||||||
self._log(f"📍 点位蛇形路径: {len(path)} 个点位, {total_machines} 台机器")
|
self._log(f"📍 点位蛇形路径: {len(path)} 个点位, {total_machines} 台机器")
|
||||||
|
|
||||||
# 重置照片上传序号(每次任务开始时重置,从1开始)
|
# 重置照片上传序号(每次任务开始时重置,从1开始)
|
||||||
self.next_upload_index = 1
|
|
||||||
|
|
||||||
# 任务步骤控制开关
|
# 任务步骤控制开关
|
||||||
if options is None:
|
if options is None:
|
||||||
@@ -258,8 +256,8 @@ class MissionExecutorV3:
|
|||||||
has_arm_pose = self.arm_client and any(abs(a) > 0.01 for a in arm_initial_pose)
|
has_arm_pose = self.arm_client and any(abs(a) > 0.01 for a in arm_initial_pose)
|
||||||
|
|
||||||
# 速度控制(从前端传入)
|
# 速度控制(从前端传入)
|
||||||
self.arm_speed = int(options.get("arm_speed", 500))
|
self.arm_speed = int(options.get("arm_speed", 1000))
|
||||||
self.agv_speed = float(options.get("agv_speed", 0.5))
|
self.agv_speed = float(options.get("agv_speed", 1.0))
|
||||||
self._log(f"🚀 AGV速度={self.agv_speed:.1f}m/s, 机械臂速度={self.arm_speed}")
|
self._log(f"🚀 AGV速度={self.agv_speed:.1f}m/s, 机械臂速度={self.arm_speed}")
|
||||||
|
|
||||||
# 设置 Nav2 导航速度(仅在任务开始时设一次)
|
# 设置 Nav2 导航速度(仅在任务开始时设一次)
|
||||||
@@ -416,6 +414,14 @@ class MissionExecutorV3:
|
|||||||
model_name = self._lookup_model(qr_value)
|
model_name = self._lookup_model(qr_value)
|
||||||
self._log(f" 🏷️ 机型: {model_name}")
|
self._log(f" 🏷️ 机型: {model_name}")
|
||||||
|
|
||||||
|
if qr_value and opt_arm_init and has_arm_pose and opt_agv_move and not self._stop.is_set():
|
||||||
|
self._log(" 🦾 扫码完成,恢复机械臂初始姿态")
|
||||||
|
try:
|
||||||
|
self.arm_client.set_angles(arm_initial_pose, speed=self.arm_speed)
|
||||||
|
self._wait_arm_ready(arm_initial_pose)
|
||||||
|
except Exception as e:
|
||||||
|
self._log(f" ⚠️ 机械臂复位失败: {e}")
|
||||||
|
|
||||||
if opt_front_photo and not self._stop.is_set():
|
if opt_front_photo and not self._stop.is_set():
|
||||||
model = self._find_model(models, model_name)
|
model = self._find_model(models, model_name)
|
||||||
if model:
|
if model:
|
||||||
@@ -644,7 +650,7 @@ class MissionExecutorV3:
|
|||||||
self._log("↪️ 调用设置页同款接口返回原点")
|
self._log("↪️ 调用设置页同款接口返回原点")
|
||||||
resp = requests.post(
|
resp = requests.post(
|
||||||
"http://127.0.0.1:5000/api/navigate/to",
|
"http://127.0.0.1:5000/api/navigate/to",
|
||||||
json={"x": 0, "y": 0, "yaw": 0},
|
json={"x": 0, "y": 0, "yaw": 0, "restore_arm": False},
|
||||||
timeout=8,
|
timeout=8,
|
||||||
)
|
)
|
||||||
data = resp.json() if resp.content else {}
|
data = resp.json() if resp.content else {}
|
||||||
@@ -721,6 +727,7 @@ class MissionExecutorV3:
|
|||||||
try:
|
try:
|
||||||
resp = requests.get(ARM_CAMERA_SNAPSHOT, timeout=QR_SCAN_TIMEOUT)
|
resp = requests.get(ARM_CAMERA_SNAPSHOT, timeout=QR_SCAN_TIMEOUT)
|
||||||
if resp.status_code != 200 or not resp.content:
|
if resp.status_code != 200 or not resp.content:
|
||||||
|
self._log(f" 📷 arm snapshot attempt {attempt+1}: HTTP {resp.status_code}, size={len(resp.content) if resp.content else 0}")
|
||||||
continue
|
continue
|
||||||
|
|
||||||
arr = np.frombuffer(resp.content, dtype=np.uint8)
|
arr = np.frombuffer(resp.content, dtype=np.uint8)
|
||||||
@@ -737,23 +744,41 @@ class MissionExecutorV3:
|
|||||||
time.sleep(0.5)
|
time.sleep(0.5)
|
||||||
return None
|
return None
|
||||||
|
|
||||||
def _request_manual_qr(self) -> Optional[str]:
|
def _request_manual_qr(self, message: str = None) -> Optional[str]:
|
||||||
"""暂停任务,等待手动输入(不超时,必须输入才能继续;stop 时解除)"""
|
"""暂停任务,等待手动输入(支持重新扫描)
|
||||||
self.status = MissionStatus.WAITING_QR
|
message: 自定义弹窗消息(None 则使用默认消息)"""
|
||||||
self.report["status"] = "waiting_qr"
|
while True:
|
||||||
self.report["step"] = "等待手动输入二维码"
|
self.status = MissionStatus.WAITING_QR
|
||||||
self._log(" ⌨️ 弹窗等待手动输入二维码(不可跳过)...")
|
self.report["status"] = "waiting_qr"
|
||||||
|
self.report["step"] = message or "等待手动输入二维码"
|
||||||
|
self.report["qr_message"] = message or "所有姿态均未识别到二维码,请手动输入:"
|
||||||
|
if message:
|
||||||
|
self._log(f" ⌨️ {message}")
|
||||||
|
else:
|
||||||
|
self._log(" ⌨️ 弹窗等待手动输入二维码...")
|
||||||
|
|
||||||
self._qr_event.clear()
|
self._qr_event.clear()
|
||||||
self._qr_event.wait() # 无限等待,直到 set_manual_qr 或 stop() 触发
|
self._qr_event.wait() # 无限等待,直到 set_manual_qr 或 stop() 触发
|
||||||
self.status = MissionStatus.RUNNING
|
|
||||||
self.report["status"] = "running"
|
if self._qr_value == 'RESCAN':
|
||||||
if self._qr_value:
|
self.status = MissionStatus.RUNNING
|
||||||
self._log(f" ✏️ 手动输入: {self._qr_value}")
|
self.report["status"] = "running"
|
||||||
return self._qr_value
|
self._log(" 🔄 用户点击重新扫描,重试...")
|
||||||
else:
|
qr = self._decode_qr_from_arm()
|
||||||
self._log(f" ⚠️ 任务已停止")
|
if qr:
|
||||||
return None
|
self._log(f" ✅ 重新扫描成功: {qr}")
|
||||||
|
return qr
|
||||||
|
self._log(" ❌ 重新扫描仍未识别到二维码")
|
||||||
|
continue # 继续弹窗
|
||||||
|
|
||||||
|
self.status = MissionStatus.RUNNING
|
||||||
|
self.report["status"] = "running"
|
||||||
|
if self._qr_value:
|
||||||
|
self._log(f" ✏️ 手动输入: {self._qr_value}")
|
||||||
|
return self._qr_value
|
||||||
|
else:
|
||||||
|
self._log(f" ⚠️ 任务已停止")
|
||||||
|
return None
|
||||||
|
|
||||||
def set_manual_qr(self, value: str):
|
def set_manual_qr(self, value: str):
|
||||||
self._qr_value = value.strip()
|
self._qr_value = value.strip()
|
||||||
@@ -762,8 +787,73 @@ class MissionExecutorV3:
|
|||||||
# ==================== 机型查询 ====================
|
# ==================== 机型查询 ====================
|
||||||
|
|
||||||
def _lookup_model(self, qr_value: Optional[str]) -> str:
|
def _lookup_model(self, qr_value: Optional[str]) -> str:
|
||||||
"""TODO: 后续通过 HTTP 接口查询机型"""
|
"""通过 /api/customs/printer 接口查询机型,同时更新查验计数
|
||||||
return "机器1"
|
如果机型不在当前报关单中/超量/查询失败,均弹窗要求重新扫码/输入(不可跳过)"""
|
||||||
|
if not qr_value:
|
||||||
|
return "机器1"
|
||||||
|
while True:
|
||||||
|
if self._stop.is_set():
|
||||||
|
return "机器1"
|
||||||
|
try:
|
||||||
|
printer_url = f"http://127.0.0.1:5000/api/customs/printer?serialNumber={qr_value}"
|
||||||
|
self._log(f" 🔍 查询机型 → {printer_url}")
|
||||||
|
resp = requests.get(printer_url, timeout=10)
|
||||||
|
self._log(f" 📡 printer 响应 HTTP {resp.status_code}: {resp.text[:500]}")
|
||||||
|
if resp.status_code == 200:
|
||||||
|
data = resp.json()
|
||||||
|
if data.get("ok"):
|
||||||
|
model = data.get("modelName", "机器1")
|
||||||
|
inv_code = data.get("inventoryCode", "")
|
||||||
|
matched = data.get("matchedItem")
|
||||||
|
has_inspection = data.get("hasInspection", False)
|
||||||
|
self._log(f" 📊 解析结果: modelName={model}, inventoryCode={inv_code}, hasInspection={has_inspection}, matched={'yes' if matched else 'no'}")
|
||||||
|
if matched:
|
||||||
|
inspected = matched.get('inspected', 0)
|
||||||
|
quantify = matched.get('quantify', 0)
|
||||||
|
# 超量检查:已查验数量超过报关单数量
|
||||||
|
if inspected >= quantify:
|
||||||
|
self._log(f" ⚠️ 机型「{model}」已查验 {inspected} 台,超过报关单数量 {quantify} 台")
|
||||||
|
new_qr = self._request_manual_qr(
|
||||||
|
f"机型「{model}」在报关单中只需查验 {quantify} 台,\n当前已扫到第 {inspected + 1} 台,请重新扫描或手动输入正确的二维码:"
|
||||||
|
)
|
||||||
|
if self._stop.is_set():
|
||||||
|
return model
|
||||||
|
qr_value = new_qr
|
||||||
|
continue
|
||||||
|
self._log(f" 🏷️ 机型: {model} (物料:{inv_code}) — 查验 {inspected}/{quantify}")
|
||||||
|
return model
|
||||||
|
elif has_inspection and not matched:
|
||||||
|
# 有查验但机型不在报关单中 → 弹窗(不可跳过)
|
||||||
|
self._log(f" ⚠️ 机型「{model}」(物料:{inv_code})不在当前报关单中")
|
||||||
|
new_qr = self._request_manual_qr(
|
||||||
|
f"二维码「{qr_value}」对应机型「{model}」不在当前报关单中,\n请重新扫描或手动输入正确的二维码:"
|
||||||
|
)
|
||||||
|
if self._stop.is_set():
|
||||||
|
return model
|
||||||
|
qr_value = new_qr
|
||||||
|
continue
|
||||||
|
else:
|
||||||
|
# 无查验或匹配成功
|
||||||
|
self._log(f" 🏷️ 机型: {model} (物料:{inv_code})")
|
||||||
|
return model
|
||||||
|
else:
|
||||||
|
self._log(f" ⚠️ printer 返回 ok=false: {data}")
|
||||||
|
# API 失败 / ok=false / 无数据 → 弹窗(不可跳过)
|
||||||
|
self._log(f" ⚠️ 查询机型失败, HTTP {resp.status_code}")
|
||||||
|
new_qr = self._request_manual_qr(
|
||||||
|
f"无法查询二维码「{qr_value}」对应的机型信息,\n请重新扫描或手动输入正确的二维码:"
|
||||||
|
)
|
||||||
|
if self._stop.is_set():
|
||||||
|
return "机器1"
|
||||||
|
qr_value = new_qr
|
||||||
|
except Exception as e:
|
||||||
|
self._log(f" ⚠️ 查询机型失败: {e}")
|
||||||
|
new_qr = self._request_manual_qr(
|
||||||
|
f"查询机型接口异常,请重新扫描或手动输入正确的二维码:"
|
||||||
|
)
|
||||||
|
if self._stop.is_set():
|
||||||
|
return "机器1"
|
||||||
|
qr_value = new_qr
|
||||||
|
|
||||||
@staticmethod
|
@staticmethod
|
||||||
def _find_model(models: list, name: str) -> Optional[dict]:
|
def _find_model(models: list, name: str) -> Optional[dict]:
|
||||||
@@ -795,6 +885,13 @@ class MissionExecutorV3:
|
|||||||
self._log(f" ⚠️ 机型无{side_label}姿态配置")
|
self._log(f" ⚠️ 机型无{side_label}姿态配置")
|
||||||
return
|
return
|
||||||
|
|
||||||
|
# 计算起始 upload_index:背面时接在正面照片之后
|
||||||
|
if side == "back":
|
||||||
|
front_poses = [p for p in all_poses if p.get("photo_type") == "front"]
|
||||||
|
base_index = len(front_poses)
|
||||||
|
else:
|
||||||
|
base_index = 0
|
||||||
|
|
||||||
self._log(f" 📷 {side_label}拍照 ({len(poses)} 个姿态)")
|
self._log(f" 📷 {side_label}拍照 ({len(poses)} 个姿态)")
|
||||||
for pi, pose in enumerate(poses):
|
for pi, pose in enumerate(poses):
|
||||||
if self._stop.is_set():
|
if self._stop.is_set():
|
||||||
@@ -818,9 +915,9 @@ class MissionExecutorV3:
|
|||||||
self.arm_client.set_angles(angles, speed=self.arm_speed)
|
self.arm_client.set_angles(angles, speed=self.arm_speed)
|
||||||
self._wait_arm_ready(angles)
|
self._wait_arm_ready(angles)
|
||||||
|
|
||||||
# 拍照(upload_index 连续递增)
|
# 拍照:正面从1开始,背面接着正面数量继续编号
|
||||||
path = self._capture_arm_photo(row, col, side, pi + 1, qr_value, upload_index=self.next_upload_index)
|
upload_index = base_index + pi + 1
|
||||||
self.next_upload_index += 1
|
path = self._capture_arm_photo(row, col, side, pi + 1, qr_value, upload_index=upload_index)
|
||||||
if path:
|
if path:
|
||||||
self._log(f" 💾 {os.path.basename(path)}")
|
self._log(f" 💾 {os.path.basename(path)}")
|
||||||
|
|
||||||
@@ -829,12 +926,12 @@ class MissionExecutorV3:
|
|||||||
upload_index: int = 0) -> Optional[str]:
|
upload_index: int = 0) -> Optional[str]:
|
||||||
"""从机械臂摄像头拍照,直接上传到服务器(不保存本地)
|
"""从机械臂摄像头拍照,直接上传到服务器(不保存本地)
|
||||||
|
|
||||||
upload_index: 从1开始,先正面后背面,由调用方维护
|
upload_index: 每台机器独立,从0开始
|
||||||
"""
|
"""
|
||||||
try:
|
try:
|
||||||
resp = requests.get(ARM_CAMERA_SNAPSHOT, timeout=10)
|
resp = requests.get(ARM_CAMERA_SNAPSHOT, timeout=10)
|
||||||
if resp.status_code != 200 or not resp.content:
|
if resp.status_code != 200 or not resp.content:
|
||||||
logger.error("arm snapshot 请求失败")
|
self._log(f" 📷 拍照 arm snapshot 失败: HTTP {resp.status_code}, size={len(resp.content) if resp.content else 0}")
|
||||||
return None
|
return None
|
||||||
|
|
||||||
# 生成文件名(用于上传)
|
# 生成文件名(用于上传)
|
||||||
@@ -843,13 +940,14 @@ class MissionExecutorV3:
|
|||||||
|
|
||||||
# 直接上传到服务器(不保存本地)
|
# 直接上传到服务器(不保存本地)
|
||||||
if qr_value:
|
if qr_value:
|
||||||
|
self._log(f" 📷 拍照成功 {len(resp.content)} bytes → {fname}")
|
||||||
self._upload_photo_bytes(fname, resp.content, qr_value, upload_index)
|
self._upload_photo_bytes(fname, resp.content, qr_value, upload_index)
|
||||||
else:
|
else:
|
||||||
self._log(" ⚠️ 无二维码,跳过上传")
|
self._log(" ⚠️ 无二维码,跳过上传")
|
||||||
|
|
||||||
return fname # 返回文件名(用于日志)
|
return fname # 返回文件名(用于日志)
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
logger.error(f"拍照异常: {e}")
|
self._log(f" ❌ 拍照异常: {e}")
|
||||||
return None
|
return None
|
||||||
|
|
||||||
def _upload_photo(self, filepath: str, serial_number: str, index: int) -> bool:
|
def _upload_photo(self, filepath: str, serial_number: str, index: int) -> bool:
|
||||||
@@ -862,13 +960,14 @@ class MissionExecutorV3:
|
|||||||
"""
|
"""
|
||||||
try:
|
try:
|
||||||
filename = os.path.basename(filepath)
|
filename = os.path.basename(filepath)
|
||||||
headers = {
|
headers = {"Authorization": ZHIJIAN_AUTH_TOKEN}
|
||||||
"Authorization": "Bearer eyJhbGciOiJIUzUxMiJ9.eyJ1c2VyX2lkIjoxLCJ1c2VyX2tleSI6ImZhNTNkZTZiLWE3NjYtNDZmNC05MDUyLTQ2MjUzZTAyNjdmNSIsInVzZXJuYW1lIjoiYWRtaW4ifQ.lC4vKThZo4aAOLsekm2kPgaEJRqRx-YDQWKfHFqxdPNESCKy57l3eIqaKTj2ZjAMaoYAwYlMrv5M1zAOJsO_PA"
|
upload_url = UPLOAD_CONFIG["url"]
|
||||||
}
|
self._log(f" 📤 上传请求 → {upload_url} | serialNumber={serial_number} | index={index} | file={filename}")
|
||||||
with open(filepath, "rb") as f:
|
with open(filepath, "rb") as f:
|
||||||
files = {"file": (filename, f, "image/jpeg")}
|
files = {"file": (filename, f, "image/jpeg")}
|
||||||
data = {"serialNumber": serial_number, "index": str(index)}
|
data = {"serialNumber": serial_number, "index": str(index)}
|
||||||
resp = requests.post(UPLOAD_URL, files=files, data=data, headers=headers, timeout=30)
|
resp = requests.post(upload_url, files=files, data=data, headers=headers, timeout=30)
|
||||||
|
self._log(f" 📡 上传响应 HTTP {resp.status_code}: {resp.text[:300]}")
|
||||||
if resp.status_code == 200:
|
if resp.status_code == 200:
|
||||||
self._log(f" ☁️ 上传成功 [{index}]: {filename}")
|
self._log(f" ☁️ 上传成功 [{index}]: {filename}")
|
||||||
return True
|
return True
|
||||||
@@ -879,6 +978,33 @@ class MissionExecutorV3:
|
|||||||
self._log(f" ❌ 上传异常 [{index}]: {e}")
|
self._log(f" ❌ 上传异常 [{index}]: {e}")
|
||||||
return False
|
return False
|
||||||
|
|
||||||
|
def _upload_photo_bytes(self, filename: str, image_data: bytes, serial_number: str, index: int) -> bool:
|
||||||
|
"""上传照片 bytes 到远程服务器(不保存本地文件)
|
||||||
|
|
||||||
|
Args:
|
||||||
|
filename: 文件名(用于服务器存储)
|
||||||
|
image_data: 图片二进制数据
|
||||||
|
serial_number: 二维码/序列号
|
||||||
|
index: 上传序号(从1开始递增)
|
||||||
|
"""
|
||||||
|
try:
|
||||||
|
headers = {"Authorization": ZHIJIAN_AUTH_TOKEN}
|
||||||
|
upload_url = UPLOAD_CONFIG["url"]
|
||||||
|
self._log(f" 📤 上传请求(内存) → {upload_url} | serialNumber={serial_number} | index={index} | file={filename}")
|
||||||
|
files = {"file": (filename, image_data, "image/jpeg")}
|
||||||
|
data = {"serialNumber": serial_number, "index": str(index)}
|
||||||
|
resp = requests.post(upload_url, files=files, data=data, headers=headers, timeout=30)
|
||||||
|
self._log(f" 📡 上传响应 HTTP {resp.status_code}: {resp.text[:300]}")
|
||||||
|
if resp.status_code == 200:
|
||||||
|
self._log(f" ☁️ 上传成功 [{index}]: {filename}")
|
||||||
|
return True
|
||||||
|
else:
|
||||||
|
self._log(f" ⚠️ 上传失败 [{index}] HTTP {resp.status_code}: {resp.text[:200]}")
|
||||||
|
return False
|
||||||
|
except Exception as e:
|
||||||
|
self._log(f" ❌ 上传异常 [{index}]: {e}")
|
||||||
|
return False
|
||||||
|
|
||||||
# ==================== 控制 ====================
|
# ==================== 控制 ====================
|
||||||
|
|
||||||
def _wait_pause(self):
|
def _wait_pause(self):
|
||||||
|
|||||||
@@ -0,0 +1,382 @@
|
|||||||
|
"""
|
||||||
|
Mock 硬件实现 - 用于本地开发环境(无真实硬件)
|
||||||
|
|
||||||
|
通过环境变量 MOCK_HARDWARE=1 启用,模拟所有硬件组件的行为。
|
||||||
|
"""
|
||||||
|
import time
|
||||||
|
import logging
|
||||||
|
import numpy as np
|
||||||
|
from typing import Tuple, List, Optional
|
||||||
|
from enum import Enum
|
||||||
|
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
logger.info("[Mock] 使用 Mock 硬件实现 - 本地开发模式")
|
||||||
|
|
||||||
|
|
||||||
|
class MockArmClient:
|
||||||
|
"""Mock 机械臂客户端"""
|
||||||
|
|
||||||
|
def __init__(self, host: str = "127.0.0.1", port: int = 5002, timeout: float = 10):
|
||||||
|
self.host = host
|
||||||
|
self.port = port
|
||||||
|
self.timeout = timeout
|
||||||
|
self._connected = False
|
||||||
|
# 默认关节角度 [J1, J2, J3, J4, J5, J6]
|
||||||
|
self._angles = [0.0, -90.0, 90.0, 0.0, 90.0, 0.0]
|
||||||
|
# 默认坐标 [x, y, z, rx, ry, rz]
|
||||||
|
self._coords = [200.0, 0.0, 300.0, 0.0, 180.0, 0.0]
|
||||||
|
self._power_on = False
|
||||||
|
self._state_on = False
|
||||||
|
# Mock socket for compatibility with real ArmClient interface
|
||||||
|
self._sock = None
|
||||||
|
|
||||||
|
class _MockSocket:
|
||||||
|
"""Mock socket for API compatibility"""
|
||||||
|
def settimeout(self, timeout):
|
||||||
|
pass
|
||||||
|
|
||||||
|
def connect(self) -> bool:
|
||||||
|
"""建立连接(Mock)"""
|
||||||
|
logger.info(f"[Mock] 连接机械臂 {self.host}:{self.port}")
|
||||||
|
self._connected = True
|
||||||
|
self._sock = self._MockSocket() # 创建 mock socket
|
||||||
|
return True
|
||||||
|
|
||||||
|
def close(self):
|
||||||
|
"""关闭连接"""
|
||||||
|
self._connected = False
|
||||||
|
self._sock = None
|
||||||
|
logger.info("[Mock] 关闭机械臂连接")
|
||||||
|
|
||||||
|
def send_command(self, cmd: str) -> Tuple[bool, str]:
|
||||||
|
"""发送命令(Mock)"""
|
||||||
|
if not self._connected:
|
||||||
|
return False, "未连接"
|
||||||
|
logger.debug(f"[Mock] 发送命令: {cmd}")
|
||||||
|
return True, "ok"
|
||||||
|
|
||||||
|
def reconnect(self) -> bool:
|
||||||
|
"""重新连接"""
|
||||||
|
self.close()
|
||||||
|
time.sleep(0.5)
|
||||||
|
return self.connect()
|
||||||
|
|
||||||
|
# ========== 机械臂命令 ==========
|
||||||
|
|
||||||
|
def get_angles(self) -> Tuple[bool, List[float]]:
|
||||||
|
"""获取所有关节角度"""
|
||||||
|
logger.debug(f"[Mock] 获取关节角度: {self._angles}")
|
||||||
|
return True, self._angles
|
||||||
|
|
||||||
|
def set_angles(self, angles: List[float], speed: int = 500) -> bool:
|
||||||
|
"""设置所有关节角度"""
|
||||||
|
if len(angles) != 6:
|
||||||
|
return False
|
||||||
|
logger.info(f"[Mock] 设置关节角度: {angles}, 速度: {speed}")
|
||||||
|
self._angles = list(angles)
|
||||||
|
return True
|
||||||
|
|
||||||
|
def set_angle(self, joint: str, angle: float, speed: int = 500) -> bool:
|
||||||
|
"""设置单个关节角度"""
|
||||||
|
logger.info(f"[Mock] 设置关节 {joint} 角度: {angle}, 速度: {speed}")
|
||||||
|
joint_map = {"J1": 0, "J2": 1, "J3": 2, "J4": 3, "J5": 4, "J6": 5}
|
||||||
|
idx = joint_map.get(joint)
|
||||||
|
if idx is not None:
|
||||||
|
self._angles[idx] = angle
|
||||||
|
return True
|
||||||
|
|
||||||
|
def jog_angle(self, joint: str, direction: int, speed: int = 500) -> bool:
|
||||||
|
"""连续调节关节角度"""
|
||||||
|
logger.debug(f"[Mock] jog_angle({joint}, {direction}, {speed})")
|
||||||
|
return True
|
||||||
|
|
||||||
|
def get_coords(self) -> Tuple[bool, List[float]]:
|
||||||
|
"""获取当前坐标和姿态"""
|
||||||
|
logger.debug(f"[Mock] 获取坐标: {self._coords}")
|
||||||
|
return True, self._coords
|
||||||
|
|
||||||
|
def set_coords(self, coords: List[float], speed: int = 500) -> bool:
|
||||||
|
"""设置坐标和姿态"""
|
||||||
|
if len(coords) != 6:
|
||||||
|
return False
|
||||||
|
logger.info(f"[Mock] 设置坐标: {coords}, 速度: {speed}")
|
||||||
|
self._coords = list(coords)
|
||||||
|
return True
|
||||||
|
|
||||||
|
def jog_coord(self, axis: str, direction: int, speed: int = 500) -> bool:
|
||||||
|
"""连续调节坐标轴"""
|
||||||
|
logger.debug(f"[Mock] jog_coord({axis}, {direction}, {speed})")
|
||||||
|
return True
|
||||||
|
|
||||||
|
def power_on(self) -> bool:
|
||||||
|
"""上电"""
|
||||||
|
logger.info("[Mock] 机械臂上电")
|
||||||
|
self._power_on = True
|
||||||
|
return True
|
||||||
|
|
||||||
|
def state_on(self) -> bool:
|
||||||
|
"""启用状态"""
|
||||||
|
logger.info("[Mock] 机械臂状态启用")
|
||||||
|
self._state_on = True
|
||||||
|
return True
|
||||||
|
|
||||||
|
def state_off(self) -> bool:
|
||||||
|
"""禁用状态"""
|
||||||
|
logger.info("[Mock] 机械臂状态禁用")
|
||||||
|
self._state_on = False
|
||||||
|
return True
|
||||||
|
|
||||||
|
def state_check(self) -> bool:
|
||||||
|
"""检查机械臂状态"""
|
||||||
|
return self._state_on
|
||||||
|
|
||||||
|
def check_running(self) -> bool:
|
||||||
|
"""检查机械臂是否在运行"""
|
||||||
|
return False # Mock模式下始终不在运行
|
||||||
|
|
||||||
|
def wait_done(self, timeout: float = 30) -> bool:
|
||||||
|
"""等待上一条命令执行完成"""
|
||||||
|
logger.debug(f"[Mock] wait_done({timeout}s) - 立即返回")
|
||||||
|
return True
|
||||||
|
|
||||||
|
def task_stop(self) -> bool:
|
||||||
|
"""停止任务"""
|
||||||
|
logger.info("[Mock] 停止机械臂任务")
|
||||||
|
return True
|
||||||
|
|
||||||
|
def __enter__(self):
|
||||||
|
self.connect()
|
||||||
|
return self
|
||||||
|
|
||||||
|
def __exit__(self, *args):
|
||||||
|
self.close()
|
||||||
|
|
||||||
|
|
||||||
|
class MockAGVController:
|
||||||
|
"""Mock AGV 控制器"""
|
||||||
|
|
||||||
|
def __init__(self, device: str = "/dev/agvpro_controller", baudrate: int = 1000000):
|
||||||
|
self.device = device
|
||||||
|
self.baudrate = baudrate
|
||||||
|
self._connected = False
|
||||||
|
self._position = [0.0, 0.0, 0.0] # [x, y, yaw]
|
||||||
|
self._voltage = 48.0 # 模拟电压
|
||||||
|
self._moving = False
|
||||||
|
|
||||||
|
def _run_ros2_cmd(self, cmd: str, timeout: float = 5.0) -> tuple:
|
||||||
|
"""执行 ros2 命令(Mock)"""
|
||||||
|
logger.debug(f"[Mock] ros2 命令: {cmd}")
|
||||||
|
return 0, "", ""
|
||||||
|
|
||||||
|
def connect(self) -> bool:
|
||||||
|
"""连接 AGV(Mock)"""
|
||||||
|
logger.info(f"[Mock] 连接 AGV 控制器 {self.device}")
|
||||||
|
self._connected = True
|
||||||
|
return True
|
||||||
|
|
||||||
|
def is_connected(self) -> bool:
|
||||||
|
"""检查是否已连接"""
|
||||||
|
return self._connected
|
||||||
|
|
||||||
|
def move_forward(self, speed: float = 1.0, duration: float = None):
|
||||||
|
"""前进"""
|
||||||
|
logger.info(f"[Mock] AGV 前进,速度: {speed}, 时长: {duration}")
|
||||||
|
self._moving = True
|
||||||
|
if duration:
|
||||||
|
time.sleep(min(duration, 0.1)) # Mock 模式下只短暂等待
|
||||||
|
self.stop()
|
||||||
|
|
||||||
|
def move_backward(self, speed: float = 1.0, duration: float = None):
|
||||||
|
"""后退"""
|
||||||
|
logger.info(f"[Mock] AGV 后退,速度: {speed}, 时长: {duration}")
|
||||||
|
if duration:
|
||||||
|
time.sleep(min(duration, 0.1))
|
||||||
|
self.stop()
|
||||||
|
|
||||||
|
def turn_left(self, speed: float = 1.0, duration: float = None):
|
||||||
|
"""左转"""
|
||||||
|
logger.info(f"[Mock] AGV 左转,速度: {speed}, 时长: {duration}")
|
||||||
|
if duration:
|
||||||
|
time.sleep(min(duration, 0.1))
|
||||||
|
self.stop()
|
||||||
|
|
||||||
|
def turn_right(self, speed: float = 1.0, duration: float = None):
|
||||||
|
"""右转"""
|
||||||
|
logger.info(f"[Mock] AGV 右转,速度: {speed}, 时长: {duration}")
|
||||||
|
if duration:
|
||||||
|
time.sleep(min(duration, 0.1))
|
||||||
|
self.stop()
|
||||||
|
|
||||||
|
def move_left_lateral(self, speed: float = 1.0, duration: float = None):
|
||||||
|
"""向左横向移动"""
|
||||||
|
logger.info(f"[Mock] AGV 向左横向移动,速度: {speed}, 时长: {duration}")
|
||||||
|
if duration:
|
||||||
|
time.sleep(min(duration, 0.1))
|
||||||
|
self.stop()
|
||||||
|
|
||||||
|
def move_right_lateral(self, speed: float = 1.0, duration: float = None):
|
||||||
|
"""向右横向移动"""
|
||||||
|
logger.info(f"[Mock] AGV 向右横向移动,速度: {speed}, 时长: {duration}")
|
||||||
|
if duration:
|
||||||
|
time.sleep(min(duration, 0.1))
|
||||||
|
self.stop()
|
||||||
|
|
||||||
|
def stop(self):
|
||||||
|
"""停止"""
|
||||||
|
logger.debug("[Mock] AGV 停止")
|
||||||
|
self._moving = False
|
||||||
|
|
||||||
|
def get_position(self) -> Optional[List[float]]:
|
||||||
|
"""获取 AGV 当前位置"""
|
||||||
|
logger.debug(f"[Mock] 获取 AGV 位置: {self._position}")
|
||||||
|
return self._position
|
||||||
|
|
||||||
|
def go_to_point(self, x: float, y: float, rz: float = None, speed: float = 1.0) -> bool:
|
||||||
|
"""移动到目标点"""
|
||||||
|
logger.info(f"[Mock] AGV 移动到目标点: ({x}, {y}), yaw: {rz}")
|
||||||
|
# 模拟移动完成
|
||||||
|
self._position = [x, y, rz if rz is not None else self._position[2]]
|
||||||
|
return True
|
||||||
|
|
||||||
|
def get_battery(self) -> Optional[float]:
|
||||||
|
"""获取电池电压"""
|
||||||
|
logger.debug(f"[Mock] 获取电池电压: {self._voltage}V")
|
||||||
|
return self._voltage
|
||||||
|
|
||||||
|
def disconnect(self):
|
||||||
|
"""断开连接"""
|
||||||
|
self.stop()
|
||||||
|
self._connected = False
|
||||||
|
|
||||||
|
def __enter__(self):
|
||||||
|
self.connect()
|
||||||
|
return self
|
||||||
|
|
||||||
|
def __exit__(self, *args):
|
||||||
|
self.disconnect()
|
||||||
|
|
||||||
|
|
||||||
|
class MockQRScanner:
|
||||||
|
"""Mock 二维码扫描器"""
|
||||||
|
|
||||||
|
def __init__(self, device_index: int = 0, width: int = 640, height: int = 400, prefer_v4l2_ctl: bool = True):
|
||||||
|
self.device_index = device_index
|
||||||
|
self.width = width
|
||||||
|
self.height = height
|
||||||
|
self._opened = False
|
||||||
|
# Mock 二维码内容
|
||||||
|
self._mock_qr_code = "MOCK_QR_SN_12345"
|
||||||
|
|
||||||
|
def open(self) -> bool:
|
||||||
|
"""打开摄像头(Mock)"""
|
||||||
|
logger.info(f"[Mock] 打开摄像头 /dev/video{self.device_index}")
|
||||||
|
self._opened = True
|
||||||
|
return True
|
||||||
|
|
||||||
|
def close(self):
|
||||||
|
"""关闭摄像头"""
|
||||||
|
self._opened = False
|
||||||
|
logger.info("[Mock] 关闭摄像头")
|
||||||
|
|
||||||
|
def read_frame(self, timeout: float = 2.0) -> Optional[np.ndarray]:
|
||||||
|
"""读取一帧(Mock)"""
|
||||||
|
if not self._opened:
|
||||||
|
return None
|
||||||
|
# 返回空白图像
|
||||||
|
return np.zeros((self.height, self.width, 3), dtype=np.uint8)
|
||||||
|
|
||||||
|
def detect_qr(self, frame: np.ndarray) -> Optional[str]:
|
||||||
|
"""从图像帧中检测二维码(Mock)"""
|
||||||
|
# 始终返回模拟二维码
|
||||||
|
logger.debug(f"[Mock] 检测到二维码: {self._mock_qr_code}")
|
||||||
|
return self._mock_qr_code
|
||||||
|
|
||||||
|
def scan_once(self) -> Optional[str]:
|
||||||
|
"""扫描一次(Mock)"""
|
||||||
|
logger.debug("[Mock] 扫描二维码...")
|
||||||
|
return self._mock_qr_code
|
||||||
|
|
||||||
|
def scan_with_retry(self, max_attempts: int = 5, interval: float = 0.5) -> Optional[str]:
|
||||||
|
"""多次扫描(Mock)"""
|
||||||
|
logger.info(f"[Mock] scan_with_retry - 直接返回模拟二维码")
|
||||||
|
return self._mock_qr_code
|
||||||
|
|
||||||
|
def get_preview_frame(self) -> Optional[np.ndarray]:
|
||||||
|
"""获取预览帧(Mock)"""
|
||||||
|
return self.read_frame()
|
||||||
|
|
||||||
|
def set_mock_qr_code(self, code: str):
|
||||||
|
"""设置模拟二维码内容(仅 Mock 模式)"""
|
||||||
|
self._mock_qr_code = code
|
||||||
|
logger.info(f"[Mock] 设置模拟二维码: {code}")
|
||||||
|
|
||||||
|
def __enter__(self):
|
||||||
|
self.open()
|
||||||
|
return self
|
||||||
|
|
||||||
|
def __exit__(self, *args):
|
||||||
|
self.close()
|
||||||
|
|
||||||
|
|
||||||
|
class MockNav2Status(Enum):
|
||||||
|
"""Mock Nav2 状态"""
|
||||||
|
IDLE = "idle"
|
||||||
|
NAVIGATING = "navigating"
|
||||||
|
SUCCEEDED = "succeeded"
|
||||||
|
FAILED = "failed"
|
||||||
|
CANCELLED = "cancelled"
|
||||||
|
|
||||||
|
|
||||||
|
class MockNav2Navigator:
|
||||||
|
"""Mock Nav2 导航器"""
|
||||||
|
|
||||||
|
def __init__(self):
|
||||||
|
self.status = MockNav2Status.IDLE
|
||||||
|
self._current_pose = [0.0, 0.0, 0.0] # [x, y, yaw]
|
||||||
|
|
||||||
|
def _get_current_pose(self) -> List[float]:
|
||||||
|
"""获取当前位置(Mock)"""
|
||||||
|
return self._current_pose
|
||||||
|
|
||||||
|
def navigate_to_pose(self, x: float, y: float, yaw: float = None,
|
||||||
|
timeout_sec: float = 120.0,
|
||||||
|
blocking: bool = True) -> bool:
|
||||||
|
"""导航到目标坐标(Mock)"""
|
||||||
|
logger.info(f"[Mock] 导航到: ({x:.3f}, {y:.3f}), yaw={yaw:.1f}°")
|
||||||
|
self.status = MockNav2Status.NAVIGATING
|
||||||
|
# 模拟导航成功
|
||||||
|
self._current_pose = [x, y, yaw if yaw is not None else 0.0]
|
||||||
|
self.status = MockNav2Status.SUCCEEDED
|
||||||
|
return True
|
||||||
|
|
||||||
|
def navigate_through_poses(self, poses: List[Tuple[float, float, float]],
|
||||||
|
timeout_per_pose: float = 120.0,
|
||||||
|
blocking: bool = True) -> bool:
|
||||||
|
"""通过多个点位导航(Mock)"""
|
||||||
|
logger.info(f"[Mock] 通过 {len(poses)} 个点位导航")
|
||||||
|
for i, (x, y, yaw) in enumerate(poses):
|
||||||
|
logger.debug(f"[Mock] 点位 {i+1}: ({x}, {y}), yaw={yaw}")
|
||||||
|
if poses:
|
||||||
|
self._current_pose = list(poses[-1])
|
||||||
|
self.status = MockNav2Status.SUCCEEDED
|
||||||
|
return True
|
||||||
|
|
||||||
|
def stop(self):
|
||||||
|
"""停止导航(Mock)"""
|
||||||
|
logger.info("[Mock] 停止导航")
|
||||||
|
self.status = MockNav2Status.IDLE
|
||||||
|
|
||||||
|
def get_status(self) -> dict:
|
||||||
|
"""获取导航状态(Mock)"""
|
||||||
|
return {
|
||||||
|
"status": self.status.value,
|
||||||
|
"position": self._current_pose
|
||||||
|
}
|
||||||
|
|
||||||
|
def get_current_position(self) -> List[float]:
|
||||||
|
"""获取当前位置(Mock)"""
|
||||||
|
return self._current_pose
|
||||||
|
|
||||||
|
|
||||||
|
# 导出兼容别名
|
||||||
|
Nav2Status = MockNav2Status
|
||||||
+220
-22
@@ -1,15 +1,20 @@
|
|||||||
"""
|
"""
|
||||||
二维码识别模块 - 使用 OpenCV 识别二维码获取 serialNumber
|
二维码识别模块 - 使用 AGV 摄像头识别二维码获取 serialNumber
|
||||||
|
|
||||||
|
优先通过 v4l2-ctl 读取 MJPG 帧,避开部分 Jetson/arm64 环境中 OpenCV
|
||||||
|
直接读取 MJPG 花屏或解码失败的问题;v4l2-ctl 不可用时回退到 OpenCV。
|
||||||
"""
|
"""
|
||||||
import cv2
|
import cv2
|
||||||
import time
|
import time
|
||||||
import logging
|
import logging
|
||||||
|
import os
|
||||||
|
import subprocess
|
||||||
import numpy as np
|
import numpy as np
|
||||||
from typing import Optional, Tuple
|
from concurrent.futures import ThreadPoolExecutor, TimeoutError as FuturesTimeout
|
||||||
|
from typing import Optional
|
||||||
|
|
||||||
logger = logging.getLogger(__name__)
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
# 尝试导入二维码识别库
|
|
||||||
try:
|
try:
|
||||||
from pyzbar.pyzbar import decode as qr_decode
|
from pyzbar.pyzbar import decode as qr_decode
|
||||||
PYZBAR_AVAILABLE = True
|
PYZBAR_AVAILABLE = True
|
||||||
@@ -19,29 +24,89 @@ except ImportError:
|
|||||||
|
|
||||||
|
|
||||||
class QRScanner:
|
class QRScanner:
|
||||||
"""二维码扫描器"""
|
"""二维码扫描器。"""
|
||||||
|
|
||||||
def __init__(self, device_index: int = 0):
|
V4L2_CTL = "/usr/bin/v4l2-ctl"
|
||||||
|
DEFAULT_WIDTH = 640
|
||||||
|
DEFAULT_HEIGHT = 400
|
||||||
|
V4L2_STREAM_COUNT = 3
|
||||||
|
V4L2_READ_TIMEOUT = 5.0
|
||||||
|
OPENCV_READ_TIMEOUT = 2.0
|
||||||
|
|
||||||
|
def __init__(
|
||||||
|
self,
|
||||||
|
device_index: int = 0,
|
||||||
|
width: int = DEFAULT_WIDTH,
|
||||||
|
height: int = DEFAULT_HEIGHT,
|
||||||
|
prefer_v4l2_ctl: bool = True,
|
||||||
|
):
|
||||||
self.device_index = device_index
|
self.device_index = device_index
|
||||||
|
self.width = width
|
||||||
|
self.height = height
|
||||||
|
self.prefer_v4l2_ctl = prefer_v4l2_ctl
|
||||||
self._cap: Optional[cv2.VideoCapture] = None
|
self._cap: Optional[cv2.VideoCapture] = None
|
||||||
self._qr_detector = cv2.QRCodeDetector() # OpenCV 内置二维码检测器
|
self._v4l2_ctl_ready = False
|
||||||
|
self._qr_detector = cv2.QRCodeDetector()
|
||||||
|
|
||||||
|
def _device_path(self) -> str:
|
||||||
|
return f"/dev/video{self.device_index}"
|
||||||
|
|
||||||
|
def _build_v4l2_cmd(self, stream_count: int = V4L2_STREAM_COUNT) -> list:
|
||||||
|
return [
|
||||||
|
self.V4L2_CTL,
|
||||||
|
"-d", self._device_path(),
|
||||||
|
"--set-fmt-video",
|
||||||
|
f"width={self.width},height={self.height},pixelformat=MJPG",
|
||||||
|
"--stream-mmap",
|
||||||
|
"--stream-to=-",
|
||||||
|
f"--stream-count={stream_count}",
|
||||||
|
]
|
||||||
|
|
||||||
|
def _check_v4l2_ctl(self) -> bool:
|
||||||
|
if not self.prefer_v4l2_ctl:
|
||||||
|
return False
|
||||||
|
if not os.path.exists(self._device_path()):
|
||||||
|
logger.warning(f"摄像头设备 {self._device_path()} 不存在,回退到 OpenCV")
|
||||||
|
return False
|
||||||
|
try:
|
||||||
|
subprocess.run(
|
||||||
|
[self.V4L2_CTL, "--version"],
|
||||||
|
capture_output=True,
|
||||||
|
timeout=2,
|
||||||
|
check=False,
|
||||||
|
)
|
||||||
|
return True
|
||||||
|
except (FileNotFoundError, subprocess.TimeoutExpired):
|
||||||
|
logger.warning(f"v4l2-ctl 不可用: {self.V4L2_CTL},回退到 OpenCV")
|
||||||
|
return False
|
||||||
|
|
||||||
def open(self) -> bool:
|
def open(self) -> bool:
|
||||||
"""打开摄像头"""
|
"""打开摄像头"""
|
||||||
|
self.close()
|
||||||
|
self._v4l2_ctl_ready = self._check_v4l2_ctl()
|
||||||
|
if self._v4l2_ctl_ready:
|
||||||
|
logger.info(
|
||||||
|
f"摄像头 {self._device_path()} 使用 v4l2-ctl MJPG 读取,"
|
||||||
|
f"分辨率 {self.width}x{self.height}"
|
||||||
|
)
|
||||||
|
return True
|
||||||
|
return self._open_opencv_capture()
|
||||||
|
|
||||||
|
def _open_opencv_capture(self) -> bool:
|
||||||
try:
|
try:
|
||||||
# 强制 V4L2 后端,获取标准彩色格式(与 test/server.py 一致)
|
|
||||||
self._cap = cv2.VideoCapture(self.device_index, cv2.CAP_V4L2)
|
self._cap = cv2.VideoCapture(self.device_index, cv2.CAP_V4L2)
|
||||||
if self._cap.isOpened():
|
if not self._cap.isOpened():
|
||||||
logger.info(f"摄像头 {self.device_index} 已打开 (V4L2)")
|
|
||||||
return True
|
|
||||||
else:
|
|
||||||
# fallback: 不指定后端
|
|
||||||
self._cap = cv2.VideoCapture(self.device_index)
|
self._cap = cv2.VideoCapture(self.device_index)
|
||||||
if self._cap.isOpened():
|
|
||||||
logger.info(f"摄像头 {self.device_index} 已打开 (默认后端)")
|
if not self._cap.isOpened():
|
||||||
return True
|
|
||||||
logger.error(f"无法打开摄像头 {self.device_index}")
|
logger.error(f"无法打开摄像头 {self.device_index}")
|
||||||
return False
|
return False
|
||||||
|
|
||||||
|
self._cap.set(cv2.CAP_PROP_CONVERT_RGB, 1)
|
||||||
|
w = int(self._cap.get(cv2.CAP_PROP_FRAME_WIDTH))
|
||||||
|
h = int(self._cap.get(cv2.CAP_PROP_FRAME_HEIGHT))
|
||||||
|
logger.info(f"摄像头 {self.device_index} 使用 OpenCV 读取,分辨率 {w}x{h}")
|
||||||
|
return True
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
logger.error(f"摄像头打开失败: {e}")
|
logger.error(f"摄像头打开失败: {e}")
|
||||||
return False
|
return False
|
||||||
@@ -50,27 +115,159 @@ class QRScanner:
|
|||||||
if self._cap:
|
if self._cap:
|
||||||
self._cap.release()
|
self._cap.release()
|
||||||
self._cap = None
|
self._cap = None
|
||||||
|
self._v4l2_ctl_ready = False
|
||||||
|
|
||||||
def read_frame(self) -> Optional[np.ndarray]:
|
@staticmethod
|
||||||
"""读取一帧"""
|
def _extract_first_jpeg(data: bytes) -> Optional[bytes]:
|
||||||
|
"""从 v4l2-ctl 输出流中提取第一帧完整 JPEG。"""
|
||||||
|
soi = data.find(b"\xff\xd8")
|
||||||
|
if soi == -1:
|
||||||
|
return None
|
||||||
|
eoi = data.find(b"\xff\xd9", soi + 2)
|
||||||
|
if eoi == -1 or eoi <= soi:
|
||||||
|
return None
|
||||||
|
return data[soi:eoi + 2]
|
||||||
|
|
||||||
|
def _read_frame_with_v4l2_ctl(self) -> Optional[np.ndarray]:
|
||||||
|
try:
|
||||||
|
proc = subprocess.run(
|
||||||
|
self._build_v4l2_cmd(),
|
||||||
|
capture_output=True,
|
||||||
|
timeout=self.V4L2_READ_TIMEOUT,
|
||||||
|
check=False,
|
||||||
|
)
|
||||||
|
jpeg_data = self._extract_first_jpeg(proc.stdout)
|
||||||
|
if jpeg_data is None or len(jpeg_data) < 100:
|
||||||
|
return None
|
||||||
|
|
||||||
|
frame = cv2.imdecode(
|
||||||
|
np.frombuffer(jpeg_data, dtype=np.uint8),
|
||||||
|
cv2.IMREAD_COLOR,
|
||||||
|
)
|
||||||
|
if frame is None:
|
||||||
|
return None
|
||||||
|
|
||||||
|
if frame.mean() < 3 or frame.mean() > 250:
|
||||||
|
return None
|
||||||
|
return frame
|
||||||
|
except subprocess.TimeoutExpired:
|
||||||
|
logger.warning("v4l2-ctl 读取超时")
|
||||||
|
return None
|
||||||
|
except Exception as e:
|
||||||
|
logger.warning(f"v4l2-ctl 读取异常: {e}")
|
||||||
|
return None
|
||||||
|
|
||||||
|
def _fix_frame(self, frame: np.ndarray) -> Optional[np.ndarray]:
|
||||||
|
"""修复绿屏/格式错误帧,返回修复后的 BGR 帧或 None"""
|
||||||
|
if frame is None:
|
||||||
|
return None
|
||||||
|
|
||||||
|
h, w = frame.shape[:2]
|
||||||
|
if h < 10 or w < 10:
|
||||||
|
return None
|
||||||
|
|
||||||
|
ndim = len(frame.shape)
|
||||||
|
|
||||||
|
# 情况 1: 2 通道原始 YUYV → 手动转换 BGR
|
||||||
|
if ndim == 2:
|
||||||
|
frame = cv2.cvtColor(frame, cv2.COLOR_YUV2BGR_YUYV)
|
||||||
|
logger.debug("YUYV 2ch → BGR 转换")
|
||||||
|
return frame
|
||||||
|
|
||||||
|
# 情况 2: 3 通道但实际帧数据显示为 YUYV(绿屏特征:G 通道全满,B/R 近空)
|
||||||
|
if ndim == 3:
|
||||||
|
g_mean = frame[:, :, 1].mean()
|
||||||
|
if g_mean > 80 and frame[:, :, 0].mean() < 30 and frame[:, :, 2].mean() < 30:
|
||||||
|
# 典型的"Lime"绿屏 — 当做 YUYV 原始数据解码
|
||||||
|
logger.debug(f"检测到绿屏 (G={g_mean:.0f}, B={frame[:,:,0].mean():.0f}, R={frame[:,:,2].mean():.0f}),尝试修复")
|
||||||
|
try:
|
||||||
|
# 把内存当做 YUYV 数据重新解析
|
||||||
|
raw_bytes = frame.tobytes()
|
||||||
|
# 3ch w*h 的数据量 = w*h*3 字节
|
||||||
|
# YUYV 每像素 2 字节,所以一幅 YUYV 图像的总字节 = w*h*2
|
||||||
|
# 我们只需要取前 w*h*2 字节作为 YUYV 数据
|
||||||
|
yuyv_len = w * h * 2
|
||||||
|
if len(raw_bytes) >= yuyv_len:
|
||||||
|
yuyv_img = np.frombuffer(raw_bytes[:yuyv_len], dtype=np.uint8).reshape(h, w, 2)
|
||||||
|
frame = cv2.cvtColor(yuyv_img, cv2.COLOR_YUV2BGR_YUYV)
|
||||||
|
logger.debug("绿屏修复完成")
|
||||||
|
return frame
|
||||||
|
except Exception as e:
|
||||||
|
logger.warning(f"绿屏修复失败: {e}")
|
||||||
|
return None
|
||||||
|
|
||||||
|
# 情况 3: 全黑帧
|
||||||
|
if frame.mean() < 5:
|
||||||
|
logger.warning("全黑帧,丢弃")
|
||||||
|
return None
|
||||||
|
|
||||||
|
# 正常 BGR 帧
|
||||||
|
return frame
|
||||||
|
|
||||||
|
def _read_frame_with_opencv(self, timeout: float) -> Optional[np.ndarray]:
|
||||||
if not self._cap or not self._cap.isOpened():
|
if not self._cap or not self._cap.isOpened():
|
||||||
return None
|
return None
|
||||||
ret, frame = self._cap.read()
|
|
||||||
if not ret:
|
pool = ThreadPoolExecutor(max_workers=1)
|
||||||
|
try:
|
||||||
|
fut = pool.submit(self._cap.read)
|
||||||
|
ret, frame = fut.result(timeout=timeout)
|
||||||
|
if not ret or frame is None:
|
||||||
|
return None
|
||||||
|
return self._fix_frame(frame)
|
||||||
|
except FuturesTimeout:
|
||||||
|
logger.warning(f"摄像头 read_frame 超时 ({timeout}s),尝试重建 _cap")
|
||||||
|
self.close()
|
||||||
|
self.open()
|
||||||
|
# 重建后重试一次
|
||||||
|
if self._cap and self._cap.isOpened():
|
||||||
|
ret, frame = self._cap.read()
|
||||||
|
if ret and frame is not None:
|
||||||
|
return self._fix_frame(frame)
|
||||||
return None
|
return None
|
||||||
return frame
|
except Exception as e:
|
||||||
|
logger.error(f"read_frame 异常: {e}")
|
||||||
|
return None
|
||||||
|
finally:
|
||||||
|
pool.shutdown(wait=False)
|
||||||
|
|
||||||
|
def read_frame(self, timeout: float = OPENCV_READ_TIMEOUT) -> Optional[np.ndarray]:
|
||||||
|
"""读取一帧。"""
|
||||||
|
if self._v4l2_ctl_ready:
|
||||||
|
frame = self._read_frame_with_v4l2_ctl()
|
||||||
|
if frame is not None:
|
||||||
|
return frame
|
||||||
|
logger.debug("v4l2-ctl 未读到有效帧,尝试 OpenCV 兜底")
|
||||||
|
if not self._cap or not self._cap.isOpened():
|
||||||
|
self._open_opencv_capture()
|
||||||
|
|
||||||
|
return self._read_frame_with_opencv(timeout)
|
||||||
|
|
||||||
def detect_qr(self, frame: np.ndarray) -> Optional[str]:
|
def detect_qr(self, frame: np.ndarray) -> Optional[str]:
|
||||||
"""从图像帧中检测二维码"""
|
"""从图像帧中检测二维码"""
|
||||||
if frame is None:
|
if frame is None:
|
||||||
return None
|
return None
|
||||||
|
|
||||||
|
if len(frame.shape) == 2:
|
||||||
|
frame = cv2.cvtColor(frame, cv2.COLOR_GRAY2BGR)
|
||||||
|
|
||||||
try:
|
try:
|
||||||
# OpenCV 内置二维码检测
|
|
||||||
data, vertices, _ = self._qr_detector.detectAndDecode(frame)
|
data, vertices, _ = self._qr_detector.detectAndDecode(frame)
|
||||||
if data and len(data) > 0:
|
if data and len(data) > 0:
|
||||||
return data.strip()
|
return data.strip()
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
logger.debug(f"二维码检测失败: {e}")
|
logger.debug(f"OpenCV QR 检测失败: {e}")
|
||||||
|
|
||||||
|
if PYZBAR_AVAILABLE:
|
||||||
|
try:
|
||||||
|
results = qr_decode(frame)
|
||||||
|
for res in results:
|
||||||
|
data = res.data.decode("utf-8").strip()
|
||||||
|
if data:
|
||||||
|
return data
|
||||||
|
except Exception as e:
|
||||||
|
logger.debug(f"pyzbar 检测失败: {e}")
|
||||||
|
|
||||||
return None
|
return None
|
||||||
|
|
||||||
def scan_once(self) -> Optional[str]:
|
def scan_once(self) -> Optional[str]:
|
||||||
@@ -81,6 +278,7 @@ class QRScanner:
|
|||||||
def scan_with_retry(self, max_attempts: int = 5, interval: float = 0.5) -> Optional[str]:
|
def scan_with_retry(self, max_attempts: int = 5, interval: float = 0.5) -> Optional[str]:
|
||||||
"""多次扫描直到成功或达到最大次数"""
|
"""多次扫描直到成功或达到最大次数"""
|
||||||
for i in range(max_attempts):
|
for i in range(max_attempts):
|
||||||
|
logger.debug(f"QR 扫描尝试 {i + 1}/{max_attempts}")
|
||||||
result = self.scan_once()
|
result = self.scan_once()
|
||||||
if result:
|
if result:
|
||||||
return result
|
return result
|
||||||
|
|||||||
@@ -211,7 +211,7 @@ local_costmap:
|
|||||||
mark_threshold: 0
|
mark_threshold: 0
|
||||||
observation_sources: scan
|
observation_sources: scan
|
||||||
scan:
|
scan:
|
||||||
topic: /scan_corrected_corrected
|
topic: /scan_corrected
|
||||||
max_obstacle_height: 2.0
|
max_obstacle_height: 2.0
|
||||||
clearing: True
|
clearing: True
|
||||||
marking: True
|
marking: True
|
||||||
@@ -247,7 +247,7 @@ global_costmap:
|
|||||||
enabled: True
|
enabled: True
|
||||||
observation_sources: scan
|
observation_sources: scan
|
||||||
scan:
|
scan:
|
||||||
topic: /scan_corrected_corrected
|
topic: /scan_corrected
|
||||||
max_obstacle_height: 2.0
|
max_obstacle_height: 2.0
|
||||||
clearing: True
|
clearing: True
|
||||||
marking: True
|
marking: True
|
||||||
|
|||||||
+115
-94
@@ -1,7 +1,7 @@
|
|||||||
"""
|
"""
|
||||||
机械臂服务端 - 机械臂端主程序
|
机械臂服务端 - 机械臂端主程序
|
||||||
运行在 10.247.46.165 上,端口 5002 (TCP) + 5003 (视频流)
|
运行在 10.247.46.165 上,端口 5002 (TCP) + 5003 (视频流)
|
||||||
通过 TCP Socket 接收 AGV 发来的指令,转发给 RoboFlow (630 Socket API)
|
通过 TCP Socket 接收 AGV 发来的指令,转发给 RoboFlow (ElephantRobot)
|
||||||
同时通过 ffmpeg 提供 HTTP 视频流
|
同时通过 ffmpeg 提供 HTTP 视频流
|
||||||
"""
|
"""
|
||||||
import socket
|
import socket
|
||||||
@@ -11,11 +11,15 @@ import logging
|
|||||||
import os
|
import os
|
||||||
import sys
|
import sys
|
||||||
import subprocess
|
import subprocess
|
||||||
|
import io
|
||||||
|
from PIL import Image
|
||||||
from flask import Flask, Response, jsonify
|
from flask import Flask, Response, jsonify
|
||||||
from werkzeug.serving import make_server
|
from werkzeug.serving import make_server
|
||||||
|
|
||||||
# 添加当前目录到路径
|
# 添加当前目录到路径
|
||||||
sys.path.insert(0, os.path.dirname(os.path.abspath(__file__)))
|
BASE_DIR = os.path.dirname(os.path.abspath(__file__))
|
||||||
|
sys.path.insert(0, BASE_DIR)
|
||||||
|
LOG_FILE = os.environ.get("ARM_SERVER_LOG_FILE", os.path.join(BASE_DIR, "server.log"))
|
||||||
|
|
||||||
# 配置日志
|
# 配置日志
|
||||||
logging.basicConfig(
|
logging.basicConfig(
|
||||||
@@ -23,7 +27,7 @@ logging.basicConfig(
|
|||||||
format="%(asctime)s [%(levelname)s] %(name)s: %(message)s",
|
format="%(asctime)s [%(levelname)s] %(name)s: %(message)s",
|
||||||
handlers=[
|
handlers=[
|
||||||
logging.StreamHandler(),
|
logging.StreamHandler(),
|
||||||
logging.FileHandler(os.path.expanduser("~/work/arm_server/server.log"))
|
logging.FileHandler(LOG_FILE)
|
||||||
]
|
]
|
||||||
)
|
)
|
||||||
logger = logging.getLogger("arm_server")
|
logger = logging.getLogger("arm_server")
|
||||||
@@ -39,6 +43,19 @@ _frame_cond = threading.Condition()
|
|||||||
_latest_frame = None
|
_latest_frame = None
|
||||||
_latest_frame_ts = 0.0
|
_latest_frame_ts = 0.0
|
||||||
_stop_ffmpeg_reader = threading.Event()
|
_stop_ffmpeg_reader = threading.Event()
|
||||||
|
_invalid_count = 0
|
||||||
|
_MAX_INVALID = 30
|
||||||
|
_MAX_BUF_SIZE = 2 * 1024 * 1024
|
||||||
|
_elephant = None
|
||||||
|
|
||||||
|
|
||||||
|
def _validate_jpeg(data):
|
||||||
|
"""验证 JPEG 数据是否有效。"""
|
||||||
|
try:
|
||||||
|
Image.open(io.BytesIO(data)).verify()
|
||||||
|
return True
|
||||||
|
except Exception:
|
||||||
|
return False
|
||||||
|
|
||||||
|
|
||||||
def _stop_ffmpeg():
|
def _stop_ffmpeg():
|
||||||
@@ -55,8 +72,8 @@ def _stop_ffmpeg():
|
|||||||
|
|
||||||
|
|
||||||
def _frame_reader():
|
def _frame_reader():
|
||||||
"""从 ffmpeg 的连续 MJPEG 输出中解析 JPEG 帧,并缓存最新一帧。"""
|
"""从 ffmpeg 的连续 MJPEG 输出中解析、校验并缓存最新一帧。"""
|
||||||
global _ffmpeg_proc, _latest_frame, _latest_frame_ts
|
global _ffmpeg_proc, _latest_frame, _latest_frame_ts, _invalid_count
|
||||||
buf = b""
|
buf = b""
|
||||||
while not _stop_ffmpeg_reader.is_set():
|
while not _stop_ffmpeg_reader.is_set():
|
||||||
proc = _ffmpeg_proc
|
proc = _ffmpeg_proc
|
||||||
@@ -70,6 +87,11 @@ def _frame_reader():
|
|||||||
time.sleep(0.02)
|
time.sleep(0.02)
|
||||||
continue
|
continue
|
||||||
buf += chunk
|
buf += chunk
|
||||||
|
if len(buf) > _MAX_BUF_SIZE:
|
||||||
|
logger.warning(f"frame buffer 超过 {_MAX_BUF_SIZE} 字节,丢弃并重启 ffmpeg")
|
||||||
|
buf = b""
|
||||||
|
_stop_ffmpeg()
|
||||||
|
continue
|
||||||
while True:
|
while True:
|
||||||
start = buf.find(b"\xff\xd8")
|
start = buf.find(b"\xff\xd8")
|
||||||
end = buf.find(b"\xff\xd9", start + 2) if start >= 0 else -1
|
end = buf.find(b"\xff\xd9", start + 2) if start >= 0 else -1
|
||||||
@@ -81,15 +103,24 @@ def _frame_reader():
|
|||||||
break
|
break
|
||||||
frame = buf[start:end + 2]
|
frame = buf[start:end + 2]
|
||||||
buf = buf[end + 2:]
|
buf = buf[end + 2:]
|
||||||
with _frame_cond:
|
if _validate_jpeg(frame):
|
||||||
_latest_frame = frame
|
with _frame_cond:
|
||||||
_latest_frame_ts = time.time()
|
_latest_frame = frame
|
||||||
_frame_cond.notify_all()
|
_latest_frame_ts = time.time()
|
||||||
|
_frame_cond.notify_all()
|
||||||
|
_invalid_count = 0
|
||||||
|
else:
|
||||||
|
_invalid_count += 1
|
||||||
|
if _invalid_count >= _MAX_INVALID:
|
||||||
|
logger.error(f"连续 {_MAX_INVALID} 帧无效,摄像头可能掉线,重启 ffmpeg")
|
||||||
|
_stop_ffmpeg()
|
||||||
|
_invalid_count = 0
|
||||||
|
break
|
||||||
|
|
||||||
|
|
||||||
def _ensure_ffmpeg():
|
def _ensure_ffmpeg():
|
||||||
"""确保 ffmpeg 进程在运行,自动重启崩溃的进程"""
|
"""确保 ffmpeg 进程在运行,自动重启崩溃的进程"""
|
||||||
global _ffmpeg_proc, _ffmpeg_thread
|
global _ffmpeg_proc, _ffmpeg_thread, _invalid_count
|
||||||
with _ffmpeg_lock:
|
with _ffmpeg_lock:
|
||||||
if _ffmpeg_proc is not None and _ffmpeg_proc.poll() is None:
|
if _ffmpeg_proc is not None and _ffmpeg_proc.poll() is None:
|
||||||
return
|
return
|
||||||
@@ -98,6 +129,7 @@ def _ensure_ffmpeg():
|
|||||||
if _ffmpeg_proc and _ffmpeg_proc.poll() is None:
|
if _ffmpeg_proc and _ffmpeg_proc.poll() is None:
|
||||||
_ffmpeg_proc.terminate()
|
_ffmpeg_proc.terminate()
|
||||||
_stop_ffmpeg_reader.clear()
|
_stop_ffmpeg_reader.clear()
|
||||||
|
_invalid_count = 0
|
||||||
|
|
||||||
logger.info(f"启动 ffmpeg 视频流 (Video{ARM_CAMERA_INDEX})")
|
logger.info(f"启动 ffmpeg 视频流 (Video{ARM_CAMERA_INDEX})")
|
||||||
_ffmpeg_proc = subprocess.Popen(
|
_ffmpeg_proc = subprocess.Popen(
|
||||||
@@ -105,11 +137,11 @@ def _ensure_ffmpeg():
|
|||||||
"ffmpeg",
|
"ffmpeg",
|
||||||
"-f", "v4l2",
|
"-f", "v4l2",
|
||||||
"-input_format", "mjpeg",
|
"-input_format", "mjpeg",
|
||||||
"-framerate", "15",
|
"-framerate", "8",
|
||||||
"-video_size", "640x480",
|
"-video_size", "640x480",
|
||||||
"-i", f"/dev/video{ARM_CAMERA_INDEX}",
|
"-i", f"/dev/video{ARM_CAMERA_INDEX}",
|
||||||
"-vf", "rotate=PI",
|
"-fflags", "nobuffer",
|
||||||
"-q:v", "8",
|
"-analyzeduration", "0",
|
||||||
"-f", "mjpeg",
|
"-f", "mjpeg",
|
||||||
"-"
|
"-"
|
||||||
],
|
],
|
||||||
@@ -164,17 +196,22 @@ def arm_camera_status():
|
|||||||
"""摄像头状态"""
|
"""摄像头状态"""
|
||||||
global _ffmpeg_proc
|
global _ffmpeg_proc
|
||||||
running = _ffmpeg_proc is not None and _ffmpeg_proc.poll() is None
|
running = _ffmpeg_proc is not None and _ffmpeg_proc.poll() is None
|
||||||
return jsonify({"opened": running, "frame_age": time.time() - _latest_frame_ts if _latest_frame_ts else None})
|
return jsonify({
|
||||||
|
"opened": running,
|
||||||
|
"frame_age": time.time() - _latest_frame_ts if _latest_frame_ts else None,
|
||||||
|
"invalid_count": _invalid_count,
|
||||||
|
})
|
||||||
|
|
||||||
|
|
||||||
@arm_video_app.route("/api/camera/restart", methods=["POST"])
|
@arm_video_app.route("/api/camera/restart", methods=["POST"])
|
||||||
def arm_camera_restart():
|
def arm_camera_restart():
|
||||||
"""重启视频流"""
|
"""重启视频流"""
|
||||||
global _latest_frame, _latest_frame_ts
|
global _latest_frame, _latest_frame_ts, _invalid_count
|
||||||
_stop_ffmpeg()
|
_stop_ffmpeg()
|
||||||
with _frame_cond:
|
with _frame_cond:
|
||||||
_latest_frame = None
|
_latest_frame = None
|
||||||
_latest_frame_ts = 0.0
|
_latest_frame_ts = 0.0
|
||||||
|
_invalid_count = 0
|
||||||
_ensure_ffmpeg()
|
_ensure_ffmpeg()
|
||||||
return jsonify({"ok": True})
|
return jsonify({"ok": True})
|
||||||
|
|
||||||
@@ -191,84 +228,18 @@ def arm_camera_snapshot():
|
|||||||
logger.warning("snapshot failed: no cached frame")
|
logger.warning("snapshot failed: no cached frame")
|
||||||
return "", 500
|
return "", 500
|
||||||
|
|
||||||
|
|
||||||
# ========== RoboFlow 630 Socket API 客户端 ==========
|
|
||||||
class RoboFlowClient:
|
|
||||||
"""通过 Socket 连接 RoboFlow 630 机械臂控制盒"""
|
|
||||||
|
|
||||||
def __init__(self, host: str = "127.0.0.1", port: int = 5001, timeout: float = 10):
|
|
||||||
self.host = host
|
|
||||||
self.port = port
|
|
||||||
self.timeout = timeout
|
|
||||||
self._sock: socket.socket = None
|
|
||||||
|
|
||||||
def connect(self) -> bool:
|
|
||||||
try:
|
|
||||||
self._sock = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
|
|
||||||
self._sock.settimeout(self.timeout)
|
|
||||||
self._sock.connect((self.host, self.port))
|
|
||||||
logger.info(f"已连接到 RoboFlow {self.host}:{self.port}")
|
|
||||||
return True
|
|
||||||
except Exception as e:
|
|
||||||
logger.error(f"连接 RoboFlow 失败: {e}")
|
|
||||||
return False
|
|
||||||
|
|
||||||
def send_recv(self, cmd: str) -> str:
|
|
||||||
"""发送命令并等待响应"""
|
|
||||||
if not self._sock:
|
|
||||||
raise ConnectionError("未连接到 RoboFlow")
|
|
||||||
try:
|
|
||||||
self._sock.sendall((cmd + "\n").encode("utf-8"))
|
|
||||||
resp = self._sock.recv(4096).decode("utf-8").strip()
|
|
||||||
return resp
|
|
||||||
except socket.timeout:
|
|
||||||
return "ERROR: timeout"
|
|
||||||
except Exception as e:
|
|
||||||
return f"ERROR: {e}"
|
|
||||||
|
|
||||||
def close(self):
|
|
||||||
if self._sock:
|
|
||||||
self._sock.close()
|
|
||||||
self._sock = None
|
|
||||||
|
|
||||||
def __enter__(self):
|
|
||||||
self.connect()
|
|
||||||
return self
|
|
||||||
|
|
||||||
def __exit__(self, *args):
|
|
||||||
self.close()
|
|
||||||
|
|
||||||
|
|
||||||
# ========== TCP 服务器 - 接收 AGV 指令 ==========
|
# ========== TCP 服务器 - 接收 AGV 指令 ==========
|
||||||
class AGVCommandServer:
|
class AGVCommandServer:
|
||||||
"""TCP 服务器,接收 AGV 发来的指令"""
|
"""TCP 服务器,接收 AGV 发来的指令,通过 ElephantRobot 转发给 RoboFlow"""
|
||||||
|
|
||||||
def __init__(self, host: str = "0.0.0.0", port: int = 5002):
|
def __init__(self, elephant, host: str = "0.0.0.0", port: int = 5002):
|
||||||
self.host = host
|
self.host = host
|
||||||
self.port = port
|
self.port = port
|
||||||
self._sock: socket.socket = None
|
self._sock: socket.socket = None
|
||||||
self._running = False
|
self._running = False
|
||||||
self.roboflow: RoboFlowClient = None
|
self._elephant = elephant
|
||||||
self._connect_roboflow()
|
if self._elephant is None:
|
||||||
|
logger.warning("ElephantRobot 实例为空,命令将返回错误")
|
||||||
def _connect_roboflow(self):
|
|
||||||
self.roboflow = RoboFlowClient()
|
|
||||||
if self.roboflow.connect():
|
|
||||||
logger.info("RoboFlow 连接成功")
|
|
||||||
# 连接成功后自动上电并激活机械臂
|
|
||||||
time.sleep(1)
|
|
||||||
try:
|
|
||||||
resp = self.roboflow.send_recv("power_on()")
|
|
||||||
logger.info(f"机械臂上电: {resp}")
|
|
||||||
except Exception as e:
|
|
||||||
logger.warning(f"机械臂上电失败: {e}")
|
|
||||||
try:
|
|
||||||
resp = self.roboflow.send_recv("state_on()")
|
|
||||||
logger.info(f"机械臂激活: {resp}")
|
|
||||||
except Exception as e:
|
|
||||||
logger.warning(f"机械臂激活失败: {e}")
|
|
||||||
else:
|
|
||||||
logger.warning("RoboFlow 连接失败,服务将以 limited 模式运行")
|
|
||||||
|
|
||||||
def start(self):
|
def start(self):
|
||||||
self._sock = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
|
self._sock = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
|
||||||
@@ -322,10 +293,10 @@ class AGVCommandServer:
|
|||||||
logger.info("AGV 客户端已断开")
|
logger.info("AGV 客户端已断开")
|
||||||
|
|
||||||
def _execute_command(self, cmd: str) -> str:
|
def _execute_command(self, cmd: str) -> str:
|
||||||
if not self.roboflow or not self.roboflow._sock:
|
if self._elephant is None:
|
||||||
return f"ERROR: RoboFlow not connected"
|
return "ERROR: Robot not initialized"
|
||||||
try:
|
try:
|
||||||
return self.roboflow.send_recv(cmd)
|
return self._elephant.send_command(cmd)
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
return f"ERROR: {e}"
|
return f"ERROR: {e}"
|
||||||
|
|
||||||
@@ -336,30 +307,80 @@ class AGVCommandServer:
|
|||||||
self._sock.close()
|
self._sock.close()
|
||||||
except:
|
except:
|
||||||
pass
|
pass
|
||||||
if self.roboflow:
|
|
||||||
self.roboflow.close()
|
|
||||||
logger.info("机械臂服务端已停止")
|
logger.info("机械臂服务端已停止")
|
||||||
|
|
||||||
|
|
||||||
# ========== 入口 ==========
|
# ========== 入口 ==========
|
||||||
|
def power_on_arm(max_retries: int = 5) -> bool:
|
||||||
|
"""通过 ElephantRobot 给机械臂上电并激活(带重试)"""
|
||||||
|
global _elephant
|
||||||
|
from pymycobot import ElephantRobot
|
||||||
|
|
||||||
|
for attempt in range(1, max_retries + 1):
|
||||||
|
try:
|
||||||
|
logger.info(f"正在通过 ElephantRobot 连接 RoboFlow (尝试 {attempt}/{max_retries})...")
|
||||||
|
el = ElephantRobot("127.0.0.1", 5001)
|
||||||
|
el.start_client()
|
||||||
|
logger.info("ElephantRobot start_client 成功,等待2秒...")
|
||||||
|
time.sleep(2)
|
||||||
|
|
||||||
|
el._power_on()
|
||||||
|
logger.info("power_on 指令已发送,等待2秒...")
|
||||||
|
time.sleep(2)
|
||||||
|
|
||||||
|
el.start_robot()
|
||||||
|
logger.info("start_robot 指令已发送,等待5秒...")
|
||||||
|
time.sleep(5)
|
||||||
|
logger.info("✅ 机械臂上电+激活 全部完成")
|
||||||
|
_elephant = el
|
||||||
|
return True
|
||||||
|
except Exception as e:
|
||||||
|
logger.warning(f"⚠️ 第 {attempt} 次尝试失败: {e}")
|
||||||
|
if attempt < max_retries:
|
||||||
|
logger.info(f"等待 3 秒后重试...")
|
||||||
|
time.sleep(3)
|
||||||
|
else:
|
||||||
|
logger.error(f"❌ 所有 {max_retries} 次尝试均失败,将以 limited 模式运行")
|
||||||
|
return False
|
||||||
|
return False
|
||||||
|
|
||||||
|
|
||||||
def main():
|
def main():
|
||||||
import signal
|
import signal
|
||||||
|
|
||||||
server = AGVCommandServer(port=5002)
|
# 先通过 ElephantRobot 给机械臂上电并激活
|
||||||
|
power_on_arm()
|
||||||
|
|
||||||
|
server = AGVCommandServer(_elephant, port=5002)
|
||||||
|
|
||||||
# 启动 Flask 视频流服务(端口 5003)
|
# 启动 Flask 视频流服务(端口 5003)
|
||||||
arm_server_http = make_server("0.0.0.0", 5003, arm_video_app, threaded=True)
|
arm_server_http = None
|
||||||
|
for attempt in range(5):
|
||||||
|
try:
|
||||||
|
arm_server_http = make_server("0.0.0.0", 5003, arm_video_app, threaded=True)
|
||||||
|
break
|
||||||
|
except OSError as e:
|
||||||
|
if attempt < 4 and "Address already in use" in str(e):
|
||||||
|
logger.warning(f"端口 5003 被占用(第{attempt+1}次),等待...")
|
||||||
|
time.sleep(3)
|
||||||
|
else:
|
||||||
|
raise
|
||||||
http_thread = threading.Thread(target=arm_server_http.serve_forever, daemon=True)
|
http_thread = threading.Thread(target=arm_server_http.serve_forever, daemon=True)
|
||||||
http_thread.start()
|
http_thread.start()
|
||||||
logger.info("机械臂视频流服务已启动: http://0.0.0.0:5003")
|
logger.info("机械臂视频流服务已启动: http://0.0.0.0:5003")
|
||||||
|
|
||||||
def signal_handler(sig, frame):
|
def signal_handler(sig, frame):
|
||||||
logger.info("收到停止信号...")
|
logger.info("收到停止信号...")
|
||||||
global _ffmpeg_proc
|
global _ffmpeg_proc, _elephant
|
||||||
if _ffmpeg_proc:
|
if _ffmpeg_proc:
|
||||||
_ffmpeg_proc.terminate()
|
_ffmpeg_proc.terminate()
|
||||||
server.stop()
|
server.stop()
|
||||||
arm_server_http.shutdown()
|
arm_server_http.shutdown()
|
||||||
|
if _elephant:
|
||||||
|
try:
|
||||||
|
_elephant.stop_client()
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
sys.exit(0)
|
sys.exit(0)
|
||||||
|
|
||||||
signal.signal(signal.SIGINT, signal_handler)
|
signal.signal(signal.SIGINT, signal_handler)
|
||||||
|
|||||||
@@ -0,0 +1,18 @@
|
|||||||
|
[Unit]
|
||||||
|
Description=Arm Server (TCP 5002 + Camera 5003)
|
||||||
|
After=network-online.target
|
||||||
|
Wants=network-online.target
|
||||||
|
|
||||||
|
[Service]
|
||||||
|
Type=simple
|
||||||
|
User=pi
|
||||||
|
EnvironmentFile=-/etc/default/arm_server
|
||||||
|
ExecStartPre=/bin/sleep 5
|
||||||
|
ExecStart=/bin/bash -lc 'export PATH="$HOME/.local/bin:/usr/local/bin:/usr/bin:/bin:$PATH"; cd "${ARM_SERVER_DIR:-$HOME/work/smart-inspection/arm_server}" && exec uv run --locked python arm_server.py'
|
||||||
|
Restart=on-failure
|
||||||
|
RestartSec=5
|
||||||
|
StandardOutput=journal
|
||||||
|
StandardError=journal
|
||||||
|
|
||||||
|
[Install]
|
||||||
|
WantedBy=multi-user.target
|
||||||
@@ -1,3 +0,0 @@
|
|||||||
# 机械臂端依赖(最少依赖)
|
|
||||||
# RoboFlow 已在树莓派上运行,此端仅做透传
|
|
||||||
flask>=1.0,<2.3
|
|
||||||
+6
-8
@@ -1,12 +1,10 @@
|
|||||||
#!/bin/bash
|
#!/bin/bash
|
||||||
# 启动机械臂服务端
|
# 启动机械臂服务端
|
||||||
|
set -e
|
||||||
|
|
||||||
cd ~/work/arm_server
|
SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)"
|
||||||
PYTHON_BIN="${PYTHON_BIN:-/usr/bin/python3}"
|
PROJECT_DIR="$(dirname "$SCRIPT_DIR")"
|
||||||
|
ARM_SERVER_DIR="${ARM_SERVER_DIR:-$PROJECT_DIR/arm_server}"
|
||||||
|
|
||||||
if ! "$PYTHON_BIN" -c "import flask" >/dev/null 2>&1; then
|
cd "$ARM_SERVER_DIR"
|
||||||
echo "Flask 未安装,正在安装 requirements.txt..."
|
exec uv run --locked python arm_server.py
|
||||||
"$PYTHON_BIN" -m pip install --user -r requirements.txt
|
|
||||||
fi
|
|
||||||
|
|
||||||
exec "$PYTHON_BIN" arm_server.py
|
|
||||||
|
|||||||
@@ -0,0 +1,882 @@
|
|||||||
|
# AGV + 机械臂 移动拍摄平台 — 技术说明文档
|
||||||
|
|
||||||
|
> **版本**: V3.0 | **更新时间**: 2026-06-17 | **作者**: 自动生成
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 目录
|
||||||
|
|
||||||
|
1. [项目概述](#1-项目概述)
|
||||||
|
2. [系统架构](#2-系统架构)
|
||||||
|
3. [硬件环境与网络拓扑](#3-硬件环境与网络拓扑)
|
||||||
|
4. [核心模块详解](#4-核心模块详解)
|
||||||
|
5. [通信协议](#5-通信协议)
|
||||||
|
6. [完整 API 接口文档](#6-完整-api-接口文档)
|
||||||
|
7. [任务执行流程](#7-任务执行流程)
|
||||||
|
8. [数据配置格式](#8-数据配置格式)
|
||||||
|
9. [部署与运维](#9-部署与运维)
|
||||||
|
10. [关键决策与约束](#10-关键决策与约束)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 1. 项目概述
|
||||||
|
|
||||||
|
### 1.1 业务目标
|
||||||
|
|
||||||
|
自动巡检拍摄系统:AGV(Automated Guided Vehicle)搭载大象机器人 630 六轴机械臂 + Orbbec Gemini 深度相机,按 M×N 网格布局自动导航到每台待检机器前,识别机器二维码→匹配机型→按预设姿态拍摄正面/背面照片→上传至后端管理系统。
|
||||||
|
|
||||||
|
### 1.2 核心能力
|
||||||
|
|
||||||
|
| 能力 | 说明 |
|
||||||
|
|------|------|
|
||||||
|
| **自主导航** | 基于 ROS2 Humble + Nav2 导航栈,读取预建地图,精确导航至每个目标坐标 |
|
||||||
|
| **多姿态拍摄** | 每台机器支持自定义正/背面多姿态(机械臂6关节角度预设) |
|
||||||
|
| **二维码识别** | 机械臂摄像头(倒装)+ 双引擎识别(pyzbar + OpenCV QRCodeDetector)|
|
||||||
|
| **蛇形路径** | M×N 网格蛇形路径优化,相邻路径点高效串联,避免无效往返 |
|
||||||
|
| **报关单查验** | 集成外部报关系统,按报关单机器清单逐台核对,自动统计查验进度 |
|
||||||
|
| **照片上传** | 拍摄后即时上传至 Java 后端文件服务,附带 serialNumber + index |
|
||||||
|
| **双摄像头** | AGV Orbbec 深度相机 + 机械臂 USB 摄像头,物理翻转纠正 + 花屏自动检测 |
|
||||||
|
| **单步执行/错误处理** | 支持单步调试模式、错误弹窗中断/跳过 |
|
||||||
|
|
||||||
|
### 1.3 技术栈
|
||||||
|
|
||||||
|
| 层级 | 技术 |
|
||||||
|
|------|------|
|
||||||
|
| **后端** | Python 3 + Flask 2.x(端口 5000) |
|
||||||
|
| **前端** | Vue 3(CDN)+ 原生 JS + HTML/CSS |
|
||||||
|
| **机器人控制** | ROS2 Humble + nav2_simple_commander |
|
||||||
|
| **机械臂** | RoboFlow 630 → TCP Socket(arm_server)|
|
||||||
|
| **导航** | Nav2 (Behavior Tree) + AMCL 定位 |
|
||||||
|
| **部署** | SSH + expect 脚本远程重启 |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 2. 系统架构
|
||||||
|
|
||||||
|
### 2.1 整体架构图
|
||||||
|
|
||||||
|
```
|
||||||
|
┌────────────────────────────────────────────────────────────────┐
|
||||||
|
│ AGV (Ubuntu 22.04) │
|
||||||
|
│ │
|
||||||
|
│ ┌──────────────────────────────────────────────────────────┐ │
|
||||||
|
│ │ Flask Web 服务 (:5000) │ │
|
||||||
|
│ │ │ │
|
||||||
|
│ │ ┌──────────┐ ┌──────────┐ ┌───────────────┐ │ │
|
||||||
|
│ │ │ 控制面板 │ │ 设置页 │ │ 任务运行页 │ │ │
|
||||||
|
│ │ │ index │ │ setting │ │ running │ │ │
|
||||||
|
│ │ └──────────┘ └──────────┘ └───────────────┘ │ │
|
||||||
|
│ │ │ │
|
||||||
|
│ │ ┌──────────────────────────────────────────────────────┐ │ │
|
||||||
|
│ │ │ GlobalState (全局状态) │ │ │
|
||||||
|
│ │ │ state / arm_client / agv_controller / qr_scanner │ │ │
|
||||||
|
│ │ └──────────────────────────────────────────────────────┘ │ │
|
||||||
|
│ │ │ │
|
||||||
|
│ │ ┌─────────────────┐ ┌──────────────────────────────┐ │ │
|
||||||
|
│ │ │ 98 个 API 端点 │ │ MissionExecutorV3 任务核 │ │ │
|
||||||
|
│ │ │ RESTful JSON │ │ M×N 网格 + 蛇形路径 │ │ │
|
||||||
|
│ │ └─────────────────┘ └──────────────────────────────┘ │ │
|
||||||
|
│ └──────────────────────────────────────────────────────────┘ │
|
||||||
|
│ │
|
||||||
|
│ ┌─────────────┐ ┌─────────────┐ ┌──────────────────────┐ │
|
||||||
|
│ │ AGVController │ │ ArmClient │ │ Nav2Navigator │ │
|
||||||
|
│ │ (ROS2/cmd_vel)│ │ TCP Socket │ │ BasicNavigator API │ │
|
||||||
|
│ └──────┬──────┘ └──────┬──────┘ └──────────┬───────────┘ │
|
||||||
|
│ │ │ │ │
|
||||||
|
│ ┌──────┴──────┐ ┌─────┴──────────┐ ┌───────┴──────────┐ │
|
||||||
|
│ │ ROS2 Topics │ │ arm_server (:5002)│ │ Nav2 Action Srv │ │
|
||||||
|
│ │ /cmd_vel │ │ RoboFlow 630 │ │ /amcl_pose │ │
|
||||||
|
│ │ /odom │ │ │ │ /navigate_to_pose│ │
|
||||||
|
│ └─────────────┘ └─────────────────┘ └──────────────────┘ │
|
||||||
|
└────────────────────────────────────────────────────────────────┘
|
||||||
|
│ │
|
||||||
|
▼ ▼
|
||||||
|
┌──────────────┐ ┌──────────────────────────────────┐
|
||||||
|
│ 机械臂 (Pi) │ │ 外部服务 (Java 后端) │
|
||||||
|
│ arm_server.py │ │ zhijian168.com / 192.168.60.159 │
|
||||||
|
│ :5002 TCP │ │ customsListPage / customsMachines│
|
||||||
|
│ :5003 Camera │ │ profile/printer / file/uploadImage│
|
||||||
|
└──────────────┘ └──────────────────────────────────┘
|
||||||
|
```
|
||||||
|
|
||||||
|
### 2.2 核心文件清单
|
||||||
|
|
||||||
|
| 文件 | 行数 | 职责 |
|
||||||
|
|------|------|------|
|
||||||
|
| `app.py` | 2132 | Flask 主程序,98 个 API 端点,GlobalState 全局状态管理 |
|
||||||
|
| `config.py` | 114 | 集中配置(IP、端口、API密钥、环境切换) |
|
||||||
|
| `utils/mission_executor.py` | 1198 | 任务执行器 V3:蛇形路径、导航、QR扫描、拍照、上传 |
|
||||||
|
| `utils/agv_controller_ros2.py` | 216 | AGV 运动控制(ROS2 topic 发布 cmd_vel) |
|
||||||
|
| `utils/arm_client.py` | 170 | 机械臂 TCP 客户端(set_angles/jog/power_on) |
|
||||||
|
| `utils/nav2_navigator.py` | 350 | Nav2 导航器(BasicNavigator API + /amcl_pose 位置) |
|
||||||
|
| `utils/qr_scanner.py` | 170 | 二维码扫描(V4L2 + 绿屏修复 + 双引擎识别) |
|
||||||
|
| `utils/image_uploader.py` | 76 | 照片 HTTP 上传(multipart/form-data) |
|
||||||
|
| `templates/index.html` | - | AGV 控制页面(实时控制 + 双摄像头预览) |
|
||||||
|
| `templates/setting.html` | - | 配置页面(网格/机型/点位/报关单) |
|
||||||
|
| `templates/running.html` | - | 任务运行页(进度 + QR弹窗 + 查验状态) |
|
||||||
|
| `static/js/app.js` | - | 控制页交互逻辑 |
|
||||||
|
| `static/js/setting.js` | - | 设置页交互逻辑 |
|
||||||
|
| `static/js/running.js` | - | 运行页交互逻辑 + SSE 实时推送 |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 3. 硬件环境与网络拓扑
|
||||||
|
|
||||||
|
### 3.1 设备清单
|
||||||
|
|
||||||
|
| 设备 | 角色 | IP 地址 | SSH 凭证 | 关键软件 |
|
||||||
|
|------|------|---------|----------|----------|
|
||||||
|
| **AGV** | 主控 + 运动平台 | `192.168.60.80` | `elephant` / `Elephant` | ROS2 Humble, Nav2, Flask |
|
||||||
|
| **机械臂 Pi** | 机械臂 + 摄像头 | `192.168.60.120` | `pi` / `elephant` | arm_server.py, RoboFlow, ffmpeg |
|
||||||
|
| **Java 测试服务器** | 报关单/上传后端 | `192.168.60.159:8080` | - | Spring Boot |
|
||||||
|
| **生产服务器** | 正式环境 | `ts.zhijian168.com` | - | HTTPS + Nginx |
|
||||||
|
|
||||||
|
### 3.2 AGV 硬件映射
|
||||||
|
|
||||||
|
| 设备 | Linux 路径 | 用途 |
|
||||||
|
|------|-----------|------|
|
||||||
|
| AGV 控制器 | `/dev/ttyCH341USB0` | AGV 底盘串口控制 |
|
||||||
|
| 雷达 | `/dev/ttyACM0` | LiDAR 传感器 |
|
||||||
|
| Orbbec Gemini | `/dev/video4` | 深度相机(彩色流 YUYV 640×480) |
|
||||||
|
|
||||||
|
### 3.3 网络参数
|
||||||
|
|
||||||
|
| 参数 | 值 | 说明 |
|
||||||
|
|------|-----|------|
|
||||||
|
| Flask 监听 | `0.0.0.0:5000` | AGV Web 服务 |
|
||||||
|
| 机械臂 TCP | `5002` | arm_server 控制端口 |
|
||||||
|
| 机械臂摄像头 | `5003` | arm_server MJPEG 流 |
|
||||||
|
| ROS_DOMAIN_ID | `1` | DDS 发现域(Flask/Nav2/AGV 节点统一) |
|
||||||
|
| AGV 串口波特率 | `1000000` | 底盘通信 |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 4. 核心模块详解
|
||||||
|
|
||||||
|
### 4.1 GlobalState — 全局状态管理
|
||||||
|
|
||||||
|
```python
|
||||||
|
class GlobalState:
|
||||||
|
state: str # "idle" | "setting" | "running" | "paused"
|
||||||
|
arm_client: ArmClient # 机械臂 TCP 客户端实例
|
||||||
|
agv_controller: AGVController # ROS2 AGV 控制器
|
||||||
|
qr_scanner: QRScanner # AGV 摄像头二维码扫描器
|
||||||
|
navigator: Nav2Navigator # 导航实例
|
||||||
|
mission_config: dict # {rows, cols, grid[][], positions[{row,col,side,coords,poses}]}
|
||||||
|
machines_config: list # [{id, row, col, front:{coords,poses}, back:{coords,poses}}]
|
||||||
|
models_config: list # [{id, name, poses:[{id,name,photo_type,arm_angles,speed}]}]
|
||||||
|
qr_config: list # [{id, name, joint_angles, qr_value, model_id}]
|
||||||
|
inspection: dict # 查验状态 {customsId, customsName, items:[{inventoryCode,quantify,inspected}]}
|
||||||
|
current_customs: dict # 当前报关单 {id, name, machine_ids}
|
||||||
|
```
|
||||||
|
|
||||||
|
**状态转换图**:
|
||||||
|
```
|
||||||
|
IDLE ──connect_all──▶ SETTING ──start_mission──▶ RUNNING
|
||||||
|
▲ ▲ │
|
||||||
|
│ │ ┌─────┼─────┐
|
||||||
|
│ │ ▼ ▼ ▼
|
||||||
|
└──disconnect── PAUSED ◀── error/stop ── COMPLETED
|
||||||
|
```
|
||||||
|
|
||||||
|
### 4.2 MissionExecutorV3 — 任务执行器核心
|
||||||
|
|
||||||
|
#### 类结构
|
||||||
|
|
||||||
|
```
|
||||||
|
MissionExecutorV3
|
||||||
|
├── 连接管理: connect_all() / disconnect_all()
|
||||||
|
├── 主流程: execute_mission(mission_config, machines, models, options)
|
||||||
|
│ ├── 蛇形路径: _build_snake_path(rows, cols, grid) → 路径列表
|
||||||
|
│ ├── 导航: _navigate(point, label) → Nav2Navigator
|
||||||
|
│ ├── QR 扫描: _scan_qr_with_poses(qr_configs, machine_row)
|
||||||
|
│ │ ├── _decode_qr_from_arm() → pyzbar/OpenCV
|
||||||
|
│ │ └── _request_manual_qr(message) → 用户手动输入
|
||||||
|
│ ├── 机型查询: _lookup_model(qr_value) → 报关单API查询
|
||||||
|
│ ├── 拍照: _shoot(model, side, row, col, qr_value, machine_row)
|
||||||
|
│ │ ├── _capture_arm_photo() → 机械臂摄像头
|
||||||
|
│ │ └── _upload_photo_bytes() → HTTP上传
|
||||||
|
│ └── 返回原点: _return_to_origin()
|
||||||
|
├── 控制: pause() / resume() / stop()
|
||||||
|
├── 单步执行: set_step_choice("confirm"|"retry"|"abort")
|
||||||
|
└── 错误处理: set_error_choice("skip"|"abort")
|
||||||
|
```
|
||||||
|
|
||||||
|
#### 蛇形路径算法
|
||||||
|
|
||||||
|
```
|
||||||
|
假设 2行 × 5列,有机器位置: (0,0)(0,1)(0,2)(0,3)(0,4)(1,0)(1,1)(1,2)(1,3)(1,4)
|
||||||
|
|
||||||
|
蛇形路径(按点位行 pr 遍历):
|
||||||
|
pr=0 (1排正面): (0,0)→(0,1)→(0,2)→(0,3)→(0,4) [左→右]
|
||||||
|
pr=1 (1排背面 + 2排正面): (1,4)→(1,3)→(1,2)→(1,1)→(1,0) [右→左]
|
||||||
|
pr=2 (2排背面): (2,0)→(2,1)→(2,2)→(2,3)→(2,4) [左→右]
|
||||||
|
|
||||||
|
PR 为奇数时列序反向。
|
||||||
|
|
||||||
|
同一点位同时服务上一行背面和下一行正面时,先执行背面,再执行正面。
|
||||||
|
镜像规则:机器行号为奇数时,所有 J1 关节角度取反(镜像)。仅 J1 取反!
|
||||||
|
```
|
||||||
|
|
||||||
|
#### 任务步骤控制开关
|
||||||
|
|
||||||
|
前端执行任务时可选择性开启/关闭步骤:
|
||||||
|
|
||||||
|
| 开关 | 字段 | 默认 | 说明 |
|
||||||
|
|------|------|------|------|
|
||||||
|
| 机械臂初始化 | `arm_init` | true | 每个点位移到后恢复默认姿态 |
|
||||||
|
| AGV 移动 | `agv_move` | true | 导航到目标坐标 |
|
||||||
|
| 二维码识别 | `qr_scan` | true | 扫描机器二维码 |
|
||||||
|
| 正面拍照 | `front_photo` | true | 正面姿态组拍摄 |
|
||||||
|
| 背面拍照 | `back_photo` | true | 背面姿态组拍摄 |
|
||||||
|
| AGV 速度 | `agv_speed` | 1.0 | m/s |
|
||||||
|
| 机械臂速度 | `arm_speed` | 1000 | RoboFlow 速度参数 |
|
||||||
|
|
||||||
|
### 4.3 AGVController — ROS2 运动控制
|
||||||
|
|
||||||
|
```python
|
||||||
|
class AGVController:
|
||||||
|
def connect() # 检查 /odom topic 是否存在
|
||||||
|
def is_connected() # 连接状态
|
||||||
|
def move_forward() # 前进 (linear.x > 0)
|
||||||
|
def move_backward() # 后退 (linear.x < 0)
|
||||||
|
def turn_left() # 左转 (angular.z > 0)
|
||||||
|
def turn_right() # 右转 (angular.z < 0)
|
||||||
|
def move_left_lateral() # 左横移 (linear.y > 0)
|
||||||
|
def move_right_lateral() # 右横移 (linear.y < 0)
|
||||||
|
def stop() # 停止 (全 0)
|
||||||
|
def get_position() # 从 /odom 获取位置 [x, y, yaw]
|
||||||
|
def get_battery() # 获取电压
|
||||||
|
```
|
||||||
|
|
||||||
|
**原理**: 通过 `subprocess` 执行 `ros2 topic pub /cmd_vel geometry_msgs/msg/Twist` 发布速度指令。`--once` 参数发布一次后退出,底层 AGV 驱动收到后会持续执行直到收到下一条指令(或发送零值停止)。
|
||||||
|
|
||||||
|
**ROS 环境**: `source /opt/ros/humble/setup.bash && source ~/agv_pro_ros2/install/setup.bash && export ROS_DOMAIN_ID=1`
|
||||||
|
|
||||||
|
### 4.4 ArmClient — 机械臂 TCP 客户端
|
||||||
|
|
||||||
|
```python
|
||||||
|
class ArmClient:
|
||||||
|
def connect() # TCP 连接到 arm_server:5002
|
||||||
|
def send_command(cmd) # 发送文本命令,接收响应
|
||||||
|
def get_angles() # → [J1..J6] 当前关节角度
|
||||||
|
def set_angles(angles, speed) # 设置全部 6 关节角度
|
||||||
|
def set_angle(joint, angle, speed) # 设置单个关节
|
||||||
|
def jog_angle(joint, direction, speed) # 连续调节(-1/0/1)
|
||||||
|
def get_coords() # → [x, y, z, rx, ry, rz]
|
||||||
|
def power_on() / state_on() / state_off() # 上电控制
|
||||||
|
def state_check() / check_running() # 状态查询
|
||||||
|
def wait_done(timeout) # 等待命令执行完成
|
||||||
|
def task_stop() # 紧急停止
|
||||||
|
```
|
||||||
|
|
||||||
|
**通信协议**: 文本行协议(`\n` 分隔)。
|
||||||
|
- **请求**: `command_name(param1,param2,...)\n`
|
||||||
|
- **响应**: `command_name:result` 或 `ok`
|
||||||
|
|
||||||
|
**关节范围** (机械臂 630):
|
||||||
|
| 关节 | 范围 |
|
||||||
|
|------|------|
|
||||||
|
| J1 | ±180° |
|
||||||
|
| J2 | ±90° |
|
||||||
|
| J3 | ±90° |
|
||||||
|
| J4 | ±180° |
|
||||||
|
| J5 | ±90° |
|
||||||
|
| J6 | ±180° |
|
||||||
|
|
||||||
|
### 4.5 Nav2Navigator — 自主导航
|
||||||
|
|
||||||
|
```python
|
||||||
|
class Nav2Navigator:
|
||||||
|
def navigate_to_pose(x, y, yaw, timeout_sec, blocking)
|
||||||
|
# 使用 BasicNavigator.goToPose() 发送导航目标
|
||||||
|
# 子线程中轮询 isTaskComplete(),超时自动取消
|
||||||
|
def navigate_through_poses(poses, timeout_per_pose, blocking)
|
||||||
|
# 多路径点连续导航
|
||||||
|
def stop() # 取消当前导航
|
||||||
|
def get_status() # {status, current_position, nav2_available}
|
||||||
|
def get_current_position() # 从 /amcl_pose topic 获取 [x,y,yaw]
|
||||||
|
```
|
||||||
|
|
||||||
|
**工作原理**:
|
||||||
|
1. 使用 `nav2_simple_commander.BasicNavigator`(官方 Python API)
|
||||||
|
2. 在子线程中初始化 `rclpy`,构造 `PoseStamped` 消息并调用 `goToPose()`
|
||||||
|
3. 轮询 `isTaskComplete()` 查看导航是否完成
|
||||||
|
4. 超时时调用 `cancelTask()` 取消
|
||||||
|
5. 位置反馈从 `/amcl_pose`(AMCL 定位结果)而非 `/odom`(里程计)获取,避免累积漂移
|
||||||
|
|
||||||
|
**返回原点机制**: `_return_to_origin()` 导航到 `(0, 0)`,超时 180 秒,最多重试 3 次。
|
||||||
|
|
||||||
|
### 4.6 QRScanner — 二维码识别
|
||||||
|
|
||||||
|
```python
|
||||||
|
class QRScanner:
|
||||||
|
def open() # 打开摄像头(V4L2,device_index=4)
|
||||||
|
def read_frame() # 读取一帧(带超时保护)
|
||||||
|
def detect_qr(frame) # 双引擎:pyzbar > OpenCV QRCodeDetector
|
||||||
|
def scan_once() # 单次扫描
|
||||||
|
def scan_with_retry(max_attempts, interval) # 多次重试
|
||||||
|
```
|
||||||
|
|
||||||
|
**双引擎策略**:
|
||||||
|
1. **pyzbar**(优先): 识别率更高,支持多种条码格式
|
||||||
|
2. **OpenCV QRCodeDetector**(兜底): pyzbar 失败时启用
|
||||||
|
|
||||||
|
**绿屏/花屏修复**: `_fix_frame()` 方法检测 YUYV 格式未转换导致的绿屏(G 通道全满),自动做 `COLOR_YUV2BGR_YUYV` 转换。全黑帧直接丢弃。
|
||||||
|
|
||||||
|
### 4.7 ImageUploader — 照片上传
|
||||||
|
|
||||||
|
```python
|
||||||
|
class ImageUploader:
|
||||||
|
def upload(image_path, serial_number, photo_index, photo_type)
|
||||||
|
def upload_batch(image_paths, serial_number, start_index)
|
||||||
|
```
|
||||||
|
|
||||||
|
**上传协议**:
|
||||||
|
- **方法**: HTTP POST(multipart/form-data)
|
||||||
|
- **URL**: `{ZHIJIAN_BASE_URL}{API_PREFIX}/file/uploadImage`
|
||||||
|
- 正式: `https://ts.zhijian168.com/prod-api/file/uploadImage`
|
||||||
|
- 测试: `http://192.168.60.159:8080/file/uploadImage`
|
||||||
|
- **字段**: `file` (MultipartFile), `serialNumber` (String), `index` (Integer)
|
||||||
|
- **认证**: `Authorization: Bearer <JWT Token>`
|
||||||
|
- **重试**: 最多 3 次,间隔 2 秒
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 5. 通信协议
|
||||||
|
|
||||||
|
### 5.1 Flask ↔ 前端
|
||||||
|
|
||||||
|
- **协议**: HTTP RESTful JSON
|
||||||
|
- **端口**: `5000`
|
||||||
|
- **格式**: `{"ok": bool, ...data}`
|
||||||
|
|
||||||
|
### 5.2 Flask ↔ AGV (ROS2)
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# 发布速度指令
|
||||||
|
ros2 topic pub /cmd_vel geometry_msgs/msg/Twist "{linear: {x: 1.0, y: 0.0, z: 0.0}, angular: {x: 0.0, y: 0.0, z: 0.0}}" --once
|
||||||
|
|
||||||
|
# 获取位置 (AMCL)
|
||||||
|
ros2 topic echo /amcl_pose --once
|
||||||
|
```
|
||||||
|
|
||||||
|
### 5.3 Flask ↔ 机械臂 (TCP)
|
||||||
|
|
||||||
|
```
|
||||||
|
请求: set_angles(-90.33,-90.08,0.16,-90.57,0.09,22.23,1000)\n
|
||||||
|
响应: set_angles:ok
|
||||||
|
|
||||||
|
请求: get_angles()\n
|
||||||
|
响应: get_angles:[-90.33,-90.08,0.16,-90.57,0.09,22.23]
|
||||||
|
```
|
||||||
|
|
||||||
|
### 5.4 Flask ↔ Java 后端
|
||||||
|
|
||||||
|
| 接口 | 方法 | URL 路径 | 说明 |
|
||||||
|
|------|------|---------|------|
|
||||||
|
| 报关单列表 | GET | `/zhijian/integration/customsListPage` | ?pageNum=&pageSize= |
|
||||||
|
| 机器列表 | GET | `/zhijian/integration/customsMachines` | ?customsId= |
|
||||||
|
| 机型查询 | GET | `/zhijian/profile/printer` | ?serialNumber= |
|
||||||
|
| 文件上传 | POST | `/file/uploadImage` | multipart/form-data |
|
||||||
|
|
||||||
|
**认证**: 所有请求携带 `Authorization: Bearer <token>` 头。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 6. 完整 API 接口文档
|
||||||
|
|
||||||
|
### 6.1 系统状态 API
|
||||||
|
|
||||||
|
| 路由 | 方法 | 说明 | 参数 |
|
||||||
|
|------|------|------|------|
|
||||||
|
| `/api/status` | GET | 全局状态(连接/地图/任务统计) | - |
|
||||||
|
| `/api/system/connect` | POST | 一次性连接所有设备 | - |
|
||||||
|
| `/api/system/disconnect` | POST | 断开所有设备 | - |
|
||||||
|
| `/api/device/connect` | POST | 连接单个设备 | `{"device":"agv\|arm\|camera\|arm_camera"}` |
|
||||||
|
|
||||||
|
### 6.2 AGV 控制 API
|
||||||
|
|
||||||
|
| 路由 | 方法 | 说明 | 参数 |
|
||||||
|
|------|------|------|------|
|
||||||
|
| `/api/agv/move` | POST | 控制移动 | `{"direction":"forward\|backward\|left\|right\|left_lateral\|right_lateral\|stop","speed":1.0}` |
|
||||||
|
| `/api/agv/position` | GET | 获取位置+电量 | - |
|
||||||
|
| `/api/agv/stop` | POST | 紧急停止 | - |
|
||||||
|
| `/api/agv/reset` | POST | 撞物体后复位 | - |
|
||||||
|
|
||||||
|
### 6.3 机械臂控制 API
|
||||||
|
|
||||||
|
| 路由 | 方法 | 说明 | 参数 |
|
||||||
|
|------|------|------|------|
|
||||||
|
| `/api/arm/get_angles` | GET | 获取当前6关节角度 | - |
|
||||||
|
| `/api/arm/set_angles` | POST | 设置全部关节 | `{"angles":[],"speed":1000}` |
|
||||||
|
| `/api/arm/set_angle` | POST | 设置单个关节 | `{"joint":"J1","angle":90,"speed":500}` |
|
||||||
|
| `/api/arm/jog` | POST | 连续调节关节 | `{"joint":"J1","direction":1\|-1\|0,"speed":500}` |
|
||||||
|
| `/api/arm/get_coords` | GET | 获取末端坐标 | - |
|
||||||
|
| `/api/arm/power_on` | POST | 上电 | - |
|
||||||
|
| `/api/arm/state_on` | POST | 激活 | - |
|
||||||
|
| `/api/arm/state_off` | POST | 去激活 | - |
|
||||||
|
| `/api/arm/state_check` | GET | 检查状态 | - |
|
||||||
|
|
||||||
|
### 6.4 摄像头 API
|
||||||
|
|
||||||
|
| 路由 | 方法 | 说明 | 参数 |
|
||||||
|
|------|------|------|------|
|
||||||
|
| `/api/camera/preview` | GET | AGV 摄像头 MJPEG 流 | - |
|
||||||
|
| `/api/camera/refresh` | GET | AGV 摄像头单帧 JPEG | - |
|
||||||
|
| `/api/camera/capture` | GET | 拍摄一张照片保存本地 | - |
|
||||||
|
| `/api/camera/arm_refresh` | GET | 机械臂摄像头单帧(翻转+花屏检测) | - |
|
||||||
|
| `/api/camera/arm_preview` | GET | 机械臂摄像头 MJPEG 代理流 | - |
|
||||||
|
| `/api/camera/qr_scan` | GET | AGV 摄像头扫码一次 | - |
|
||||||
|
| `/api/camera/capabilities` | GET | 摄像头能力信息 | - |
|
||||||
|
|
||||||
|
### 6.5 地图导航 API
|
||||||
|
|
||||||
|
| 路由 | 方法 | 说明 | 参数 |
|
||||||
|
|------|------|------|------|
|
||||||
|
| `/api/map/load` | POST | 加载地图文件 | `{"map_dir":"...","map_file":"map.yaml"}` |
|
||||||
|
| `/api/map/save` | POST | 保存地图配置 | `{"map_dir":"...","map_file":"map.yaml"}` |
|
||||||
|
| `/api/map/image` | GET | 获取地图 PNG 图像 | - |
|
||||||
|
| `/api/map/meta` | GET | 获取地图元数据(分辨率/原点/尺寸) | - |
|
||||||
|
| `/api/navigate/to` | POST | 导航到目标坐标 | `{"x":1.0,"y":2.0,"yaw":0.0}` |
|
||||||
|
| `/api/navigate/stop` | POST | 停止导航 | - |
|
||||||
|
| `/api/navigate/cancel` | POST | 取消导航 | - |
|
||||||
|
| `/api/navigate/status` | GET | 获取导航状态 | - |
|
||||||
|
| `/api/navigate/path` | POST | 预览路径(Nav2 不可用) | `{"x":1.0,"y":2.0}` |
|
||||||
|
|
||||||
|
### 6.6 任务执行 API
|
||||||
|
|
||||||
|
| 路由 | 方法 | 说明 | 参数 |
|
||||||
|
|------|------|------|------|
|
||||||
|
| `/api/mission/start` | POST | 开始执行任务 | `{"single_step":false,"arm_init":true,"agv_move":true,"qr_scan":true,"front_photo":true,"back_photo":true,"agv_speed":1.0,"arm_speed":1000}` |
|
||||||
|
| `/api/mission/stop` | POST | 停止任务 | - |
|
||||||
|
| `/api/mission/pause` | POST | 暂停任务 | - |
|
||||||
|
| `/api/mission/resume` | POST | 恢复任务 | - |
|
||||||
|
| `/api/mission/report` | GET | 获取执行报告 | - |
|
||||||
|
| `/api/mission/state` | GET | 任务实时状态(步骤/进度/查验/QR消息) | - |
|
||||||
|
| `/api/mission/log` | GET | 实时日志 | - |
|
||||||
|
| `/api/mission/manual-qr` | POST | 手动输入二维码(弹窗提交) | `{"qr":"BG042110276"}` |
|
||||||
|
| `/api/mission/error-skip` | POST | 错误弹窗:跳过 | - |
|
||||||
|
| `/api/mission/error-abort` | POST | 错误弹窗:中断 | - |
|
||||||
|
| `/api/mission/singlestep/confirm` | POST | 单步确认 | - |
|
||||||
|
| `/api/mission/singlestep/retry` | POST | 单步重试 | - |
|
||||||
|
| `/api/mission/singlestep/abort` | POST | 单步中断 | - |
|
||||||
|
|
||||||
|
### 6.7 任务配置 API
|
||||||
|
|
||||||
|
| 路由 | 方法 | 说明 | 参数 |
|
||||||
|
|------|------|------|------|
|
||||||
|
| `/api/mission/config` | GET | 获取网格配置+空位矩阵 | - |
|
||||||
|
| `/api/mission/config` | POST | 设置网格配置 | `{"rows":2,"cols":5,"grid":[[],...],"arm_initial_pose":[]}` |
|
||||||
|
| `/api/mission/position` | GET | 获取 AGV 当前位置(设置点位用) | - |
|
||||||
|
| `/api/mission/init_pose` | POST | 将 AMCL 初始位置设为 (0,0,0) | - |
|
||||||
|
| `/api/mission/positions` | GET | 获取所有点位坐标 | - |
|
||||||
|
| `/api/mission/positions` | POST | 保存/更新单点位 | `{"row":0,"col":0,"side":"front","coords":[],"poses":[]}` |
|
||||||
|
| `/api/mission/machines` | GET | 获取所有机器配置 | - |
|
||||||
|
| `/api/mission/machines` | POST | 批量保存机器配置 | `{"machines":[...]}` |
|
||||||
|
| `/api/mission/machines/add` | POST | 添加单台机器 | `{"row":0,"col":0,"front":{},"back":{}}` |
|
||||||
|
| `/api/mission/machines/<id>` | PUT | 更新机器配置 | |
|
||||||
|
| `/api/mission/machines/<id>` | DELETE | 删除机器配置 | |
|
||||||
|
| `/api/mission/poses/<id>/<side>` | GET | 获取机器指定侧姿态 | - |
|
||||||
|
| `/api/mission/poses/<id>/<side>` | POST | 添加姿态到机器 | `{"arm_angles":[],"speed":500}` |
|
||||||
|
| `/api/mission/poses/<id>/<side>/<pid>` | DELETE | 删除姿态 | - |
|
||||||
|
| `/api/mission/qr_scan/<id>` | POST | AGV 摄像头扫码关联机器 | - |
|
||||||
|
| `/api/mission/generate_sequence` | GET | 生成蛇形拍摄序列预览 | - |
|
||||||
|
|
||||||
|
### 6.8 机型配置 API
|
||||||
|
|
||||||
|
| 路由 | 方法 | 说明 | 参数 |
|
||||||
|
|------|------|------|------|
|
||||||
|
| `/api/models/list` | GET | 获取所有机型 | - |
|
||||||
|
| `/api/models/add` | POST | 添加机型 | `{"name":"机型1","serial_prefix":"BG"}` |
|
||||||
|
| `/api/models/<id>` | POST | 更新机型 | - |
|
||||||
|
| `/api/models/<id>` | DELETE | 删除机型 | - |
|
||||||
|
| `/api/models/poses/add` | POST | 添加姿态到机型 | `{"model_id":"xxx","name":"正1","photo_type":"front","arm_angles":[]}` |
|
||||||
|
| `/api/models/<id>/poses` | GET | 获取机型姿态列表 | - |
|
||||||
|
| `/api/models/<id>/poses/<pid>` | PUT | 更新姿态 | - |
|
||||||
|
| `/api/models/<id>/poses/<pid>` | DELETE | 删除姿态 | - |
|
||||||
|
|
||||||
|
### 6.9 二维码配置 API
|
||||||
|
|
||||||
|
| 路由 | 方法 | 说明 | 参数 |
|
||||||
|
|------|------|------|------|
|
||||||
|
| `/api/qr/configs` | GET | 获取所有二维码配置 | - |
|
||||||
|
| `/api/qr/configs` | POST | 添加二维码配置 | `{"name":"二维码1","joint_angles":[]}` |
|
||||||
|
| `/api/qr/configs/<id>` | PUT | 更新二维码配置 | - |
|
||||||
|
| `/api/qr/configs/<id>` | DELETE | 删除二维码配置 | - |
|
||||||
|
| `/api/qr/configs/<id>/read-angles` | POST | 读取当前臂角度写入配置 | - |
|
||||||
|
| `/api/qr/scan/<id>` | POST | 机械臂摄像头扫码保存 | - |
|
||||||
|
|
||||||
|
### 6.10 报关单与查验 API
|
||||||
|
|
||||||
|
| 路由 | 方法 | 说明 | 参数 |
|
||||||
|
|------|------|------|------|
|
||||||
|
| `/api/customs/list` | GET | 报关单列表(代理) | `?pageNum=1&pageSize=50` |
|
||||||
|
| `/api/customs/machines` | GET | 报关单机器列表(代理) | `?customsId=xxx` |
|
||||||
|
| `/api/customs/selected` | POST | 设定当前报关单 | `{"id":"xxx","name":"xxx","machine_ids":[]}` |
|
||||||
|
| `/api/customs/selected` | GET | 获取当前报关单 | - |
|
||||||
|
| `/api/customs/printer` | GET | 查询机型+更新查验计数 | `?serialNumber=xxx` |
|
||||||
|
| `/api/customs/inspection/start` | POST | 开始查验 | `{"customsId":"xxx"}` |
|
||||||
|
| `/api/customs/inspection` | GET | 获取查验状态 | - |
|
||||||
|
| `/api/customs/inspection/end` | POST | 结束查验 | - |
|
||||||
|
| `/api/customs/inspection/update` | POST | 直接更新计数 | `{"inventoryCode":"xxx"}` |
|
||||||
|
|
||||||
|
### 6.11 环境切换 API
|
||||||
|
|
||||||
|
| 路由 | 方法 | 说明 | 参数 |
|
||||||
|
|------|------|------|------|
|
||||||
|
| `/api/config/mode` | GET | 获取当前环境 | - |
|
||||||
|
| `/api/config/mode` | POST | 切换测试/正式环境 | `{"test_mode":true}` |
|
||||||
|
|
||||||
|
**环境差异**:
|
||||||
|
| 项目 | 测试环境 | 正式环境 |
|
||||||
|
|------|---------|---------|
|
||||||
|
| Base URL | `http://192.168.60.159:8080` | `https://ts.zhijian168.com` |
|
||||||
|
| API 前缀 | 空 | `/prod-api` |
|
||||||
|
| 上传地址 | `http://192.168.60.159:8080/file/uploadImage` | `https://ts.zhijian168.com/prod-api/file/uploadImage` |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 7. 任务执行流程
|
||||||
|
|
||||||
|
### 7.1 完整生命周期
|
||||||
|
|
||||||
|
```
|
||||||
|
[1] 前端设置页配置
|
||||||
|
├── 加载地图 → 设置 M×N 网格尺寸(rows/cols)
|
||||||
|
├── 标注空位(Machine Toggle 切换每个单元格有/无机器)
|
||||||
|
├── 逐点位标定坐标(AGV 开到机器前→读取位置→保存)
|
||||||
|
├── 配置二维码扫描角度(机械臂对准二维码位置)
|
||||||
|
├── 配置机型姿态组(正/背面,每面多角度)
|
||||||
|
└── 连接设备(AGV/机械臂/摄像头)
|
||||||
|
|
||||||
|
[2] 报关单查验
|
||||||
|
├── 选择报关单 → 开始查验
|
||||||
|
└── 系统按 inventoryCode 聚合统计各机型待查验数量
|
||||||
|
|
||||||
|
[3] 启动任务
|
||||||
|
├── POST /api/mission/start(可选单步模式+步骤开关)
|
||||||
|
└── MissionExecutorV3.execute_mission() 在新线程中运行
|
||||||
|
|
||||||
|
[4] 逐点位蛇形执行
|
||||||
|
For each 点位 (pr, c) in 蛇形路径:
|
||||||
|
├── [可选] 恢复机械臂初始姿态
|
||||||
|
├── [可选] 导航到该点位坐标
|
||||||
|
│ └── Nav2Navigator.navigate_to_pose() → BasicNavigator.goToPose()
|
||||||
|
│
|
||||||
|
├── 背面操作(如果 pr>0 且 (pr-1,c) 有机器)
|
||||||
|
│ ├── 切换到 QR 扫描姿态(可选)
|
||||||
|
│ ├── 扫描二维码 → 查机型 → [可选] 拍照
|
||||||
|
│ └── 上传照片 + 更新查验计数
|
||||||
|
│
|
||||||
|
└── 正面操作(如果 pr<rows 且 (pr,c) 有机器)
|
||||||
|
├── 切换到 QR 扫描姿态
|
||||||
|
├── _scan_qr_with_poses(qr_configs):
|
||||||
|
│ ├── 逐姿态尝试扫描(pyzbar + OpenCV)
|
||||||
|
│ ├── 失败 → 弹窗 _request_manual_qr()
|
||||||
|
│ └── 机型不在报关单 → 弹窗重新输入(不可跳过)
|
||||||
|
│
|
||||||
|
├── _lookup_model(qr_value):
|
||||||
|
│ ├── 请求 /api/customs/printer?serialNumber=xxx
|
||||||
|
│ ├── 超量检查(inspected >= quantify)
|
||||||
|
│ └── 返回机型名称
|
||||||
|
│
|
||||||
|
└── _shoot(model, "front"):
|
||||||
|
├── 逐姿态设置关节角度 + 等待就位
|
||||||
|
├── _capture_arm_photo() → 机械臂摄像头拍照
|
||||||
|
├── _upload_photo_bytes() → HTTP上传
|
||||||
|
└── 更新查验计数
|
||||||
|
|
||||||
|
[5] 任务完成
|
||||||
|
├── _return_to_origin() → 导航回 (0,0)
|
||||||
|
└── 生成执行报告
|
||||||
|
```
|
||||||
|
|
||||||
|
### 7.2 QR 扫描流程详解
|
||||||
|
|
||||||
|
```
|
||||||
|
_scan_qr_with_poses(qr_configs, machine_row):
|
||||||
|
1. 逐 QR 配置尝试
|
||||||
|
├── set_angles(qr_config.joint_angles) → 机械臂移到扫码位
|
||||||
|
├── _wait_arm_ready() → 等待到位(容差 2°)
|
||||||
|
└── _decode_qr_from_arm():
|
||||||
|
├── HTTP GET 机械臂摄像头单帧
|
||||||
|
├── 花屏检测 (_is_corrupted_jpeg)
|
||||||
|
├── pyzbar.decode() → 识别成功
|
||||||
|
└── OpenCV QRCodeDetector → 兜底
|
||||||
|
|
||||||
|
2. 如果识别失败:
|
||||||
|
├── 报错日志 + 弹窗 _request_manual_qr()
|
||||||
|
└── 强制用户扫描/输入(不可跳过,仅任务停止可退出)
|
||||||
|
|
||||||
|
3. 如果机型不在报关单 (_lookup_model 返回 matched=null):
|
||||||
|
├── 弹窗 _request_manual_qr() 强制重新输入
|
||||||
|
└── 循环直到匹配或任务停止
|
||||||
|
|
||||||
|
4. 如果已查验数量 ≥ 报关单数量 (_lookup_model 检测超量):
|
||||||
|
├── 弹窗 _request_manual_qr() 强制重新输入
|
||||||
|
└── 循环直到不超量或任务停止
|
||||||
|
```
|
||||||
|
|
||||||
|
### 7.3 拍照流程详解
|
||||||
|
|
||||||
|
```
|
||||||
|
_shoot(model, side, row, col, qr_value, machine_row):
|
||||||
|
1. 过滤姿态: 只取 photo_type == side 的姿态
|
||||||
|
2. 镜像规则: machine_row % 2 == 1 → J1 = -J1
|
||||||
|
3. 逐姿态执行:
|
||||||
|
├── set_angles(pose.arm_angles, speed)
|
||||||
|
├── _wait_arm_ready() → 等待姿态稳定
|
||||||
|
├── _capture_arm_photo():
|
||||||
|
│ ├── HTTP GET 机械臂摄像头 JPG
|
||||||
|
│ ├── 花屏检测
|
||||||
|
│ └── 保存到 /home/elephant/photos/
|
||||||
|
└── _upload_photo_bytes():
|
||||||
|
├── POST multipart/form-data
|
||||||
|
├── serialNumber = qr_value
|
||||||
|
├── index = next_upload_index(全局递增,从1开始)
|
||||||
|
└── 重试3次
|
||||||
|
4. 日志: "拍照完成 (机型=Mxx, 面=正面, 位置=r-c)"
|
||||||
|
```
|
||||||
|
|
||||||
|
### 7.4 错误处理
|
||||||
|
|
||||||
|
| 场景 | 触发条件 | 处理方式 |
|
||||||
|
|------|---------|---------|
|
||||||
|
| 导航失败 | Nav2 超时/返回 failed | 错误弹窗(跳过/中断) |
|
||||||
|
| QR 识别失败 | 所有姿态尝试均未识别 | 手动输入弹窗(不能跳过) |
|
||||||
|
| 机型不在报关单 | printer 返回空 matchedItem | 手动输入弹窗(不能跳过) |
|
||||||
|
| 查验超量 | inspected >= quantify | 手动输入弹窗(不能跳过) |
|
||||||
|
| 拍照失败 | HTTP 请求/文件损坏 | 记录日志,继续下一张 |
|
||||||
|
| 上传失败 | HTTP 超时/401/非200 | 重试3次,记录日志 |
|
||||||
|
| 机械臂超时 | _wait_arm_ready 15秒超时 | 记录实际偏差,继续执行 |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 8. 数据配置格式
|
||||||
|
|
||||||
|
### 8.1 任务网格配置 (mission_config.json)
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"rows": 2,
|
||||||
|
"cols": 5,
|
||||||
|
"grid": [[true, true, true, true, true],
|
||||||
|
[true, true, true, true, true]],
|
||||||
|
"positions": [
|
||||||
|
{"row": 0, "col": 0, "side": "front", "coords": [0.54, -1.32, -0.05], "poses": []},
|
||||||
|
{"row": 1, "col": 0, "side": "back", "coords": [0.65, -3.63, -3.06], "poses": []}
|
||||||
|
],
|
||||||
|
"arm_initial_pose": [-90.33, -90.08, 0.16, -90.57, 0.09, 22.23]
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
- `grid[r][c]` = `true` 表示该位置有机器
|
||||||
|
- `positions` 中 `row=pr` 表示点位行(非机器行),机器行 `mr = pr` (正面) 或 `mr = pr-1` (背面)
|
||||||
|
- `coords = [x, y, yaw]` 地图坐标和朝向
|
||||||
|
|
||||||
|
### 8.2 机器配置 (machines_config.json)
|
||||||
|
|
||||||
|
```json
|
||||||
|
[{
|
||||||
|
"id": "m_0_0",
|
||||||
|
"row": 0, "col": 0,
|
||||||
|
"front": {
|
||||||
|
"coords": [0.54, -1.32, -0.05],
|
||||||
|
"poses": [{"id":"pose_xxx","name":"正1","arm_angles":[...],"speed":500}]
|
||||||
|
},
|
||||||
|
"back": {
|
||||||
|
"coords": [0.65, -3.63, -3.06],
|
||||||
|
"poses": [{"id":"pose_xxx","name":"背1","arm_angles":[...],"speed":500}]
|
||||||
|
}
|
||||||
|
}]
|
||||||
|
```
|
||||||
|
|
||||||
|
### 8.3 机型配置 (models_config.json)
|
||||||
|
|
||||||
|
```json
|
||||||
|
[{
|
||||||
|
"id": "m_1778767289",
|
||||||
|
"name": "MXM465N",
|
||||||
|
"serial_prefix": "BG",
|
||||||
|
"poses": [
|
||||||
|
{
|
||||||
|
"id": "pose_xxx1",
|
||||||
|
"name": "正面姿态1",
|
||||||
|
"photo_type": "front",
|
||||||
|
"arm_angles": [-93.59, -184.34, 50.58, -38.33, -85.15, 20.40],
|
||||||
|
"speed": 500
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "pose_xxx2",
|
||||||
|
"name": "背面姿态1",
|
||||||
|
"photo_type": "back",
|
||||||
|
"arm_angles": [15.86, -161.13, 138.0, -162.0, 168.0, 15.65],
|
||||||
|
"speed": 500
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}]
|
||||||
|
```
|
||||||
|
|
||||||
|
- `photo_type`: `"front"` / `"back"` / `"nameplate"`
|
||||||
|
- `arm_angles`: `[J1, J2, J3, J4, J5, J6]` 单位为度
|
||||||
|
|
||||||
|
### 8.4 二维码扫描姿态 (qr_config.json)
|
||||||
|
|
||||||
|
```json
|
||||||
|
[{
|
||||||
|
"id": "qr_001",
|
||||||
|
"name": "正面扫码位",
|
||||||
|
"joint_angles": [-89.80, -2.01, -87.18, -82.50, -93.32, 20.40],
|
||||||
|
"qr_value": "",
|
||||||
|
"model_id": ""
|
||||||
|
}]
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 9. 部署与运维
|
||||||
|
|
||||||
|
### 9.1 环境要求
|
||||||
|
|
||||||
|
**AGV (主控)**:
|
||||||
|
- Ubuntu 22.04 (ROS2 Humble)
|
||||||
|
- uv 管理的 Python 3.10 虚拟环境
|
||||||
|
- OpenCV (cv2), Flask, requests, numpy, pyzbar, PyYAML
|
||||||
|
- ROS2 Humble + nav2_simple_commander
|
||||||
|
- 系统依赖:ffmpeg、libzbar0
|
||||||
|
|
||||||
|
**机械臂 (Pi)**:
|
||||||
|
- arm_server.py(TCP 服务器端口 5002)
|
||||||
|
- arm_camera.py(MJPEG 服务器端口 5003)
|
||||||
|
- RoboFlow(大象机器人 SDK)
|
||||||
|
- uv 管理的 Python 3.10 虚拟环境
|
||||||
|
|
||||||
|
### 9.2 启动流程
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# === 首次部署 / 依赖同步 ===
|
||||||
|
cd ~/work/smart-inspection
|
||||||
|
uv sync
|
||||||
|
|
||||||
|
# === 机械臂端 (Pi) ===
|
||||||
|
# 1. 启动 arm_server (TCP 5002) + arm_camera (MJPEG 5003)
|
||||||
|
sudo systemctl start arm_server
|
||||||
|
|
||||||
|
# === AGV 端 ===
|
||||||
|
# 2. 完整启动 ROS2 导航栈 + Flask
|
||||||
|
cd ~/work/smart-inspection
|
||||||
|
./scripts/prod-backend.sh
|
||||||
|
```
|
||||||
|
|
||||||
|
### 9.3 部署命令
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# 本地 → AGV 部署:同步仓库根目录后,在 AGV 上执行
|
||||||
|
cd ~/work/smart-inspection
|
||||||
|
uv sync --locked
|
||||||
|
|
||||||
|
# 部署后验证远程文件
|
||||||
|
ssh elephant@192.168.60.80 "grep 'def _lookup_model' /home/elephant/work/smart-inspection/agv_app/utils/mission_executor.py"
|
||||||
|
|
||||||
|
# 重启生产后端
|
||||||
|
ssh elephant@192.168.60.80 'cd ~/work/smart-inspection && ./scripts/prod-backend.sh'
|
||||||
|
|
||||||
|
# 清空 Python 缓存(关键!修改后必须清)
|
||||||
|
ssh elephant@192.168.60.80 "find /home/elephant/work/smart-inspection/agv_app -name '*.pyc' -delete; find /home/elephant/work/smart-inspection/agv_app -name '__pycache__' -type d -exec rm -rf {} +"
|
||||||
|
```
|
||||||
|
|
||||||
|
### 9.4 关键运维经验
|
||||||
|
|
||||||
|
| 问题 | 根因 | 解决方案 |
|
||||||
|
|------|------|---------|
|
||||||
|
| Flask 模板/JS 不生效 | 模板缓存 | 重启 Flask 服务 |
|
||||||
|
| Python 修改不生效 | `__pycache__` 缓存 | 删除所有 .pyc 和 __pycache__ |
|
||||||
|
| V4L2 摄像头无响应 | 设备独占 | kill 残留进程后重开 |
|
||||||
|
| ROS2 节点互相不可见 | ROS_DOMAIN_ID 不一致 | 统一设为 1 |
|
||||||
|
| 导航 DDS 发现失败 | FastRTPS 共享内存残留 | `rm -f /dev/shm/fastrtps_*` |
|
||||||
|
| 机械臂摄像头花屏 | USB 掉线致 ffmpeg 读失效 fd | arm_server 添加 JPEG 校验+自动重连 |
|
||||||
|
| Flask SIGTERM 被杀 | sshpass+nohup 触发 | 用 expect 脚本重启 |
|
||||||
|
| 照片上传 405 | 缺少 `/prod-api` 前缀 | config.py 动态拼接 API_PREFIX |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 10. 关键决策与约束
|
||||||
|
|
||||||
|
### 10.1 架构决策
|
||||||
|
|
||||||
|
| 决策 | 原因 |
|
||||||
|
|------|------|
|
||||||
|
| **agv_controller 用 ROS2 CLI 而非 rclpy** | 避免 rclpy 初始化与 Flask 多线程冲突 |
|
||||||
|
| **Nav2 用 BasicNavigator API 而非 subprocess** | 原生 API 更可靠(subprocess 的 stdin pipe 有 Humble bug) |
|
||||||
|
| **机械臂用 TCP Socket 而非 pymycobot** | pymycobot 存在死锁问题 |
|
||||||
|
| **位置源用 /amcl_pose 而非 /odom** | /odom 累积漂移,/amcl_pose 有地图校正 |
|
||||||
|
| **Vue 用 `{% raw %}` 包裹** | Jinja2 与 Vue 3 `{{}}` 冲突 |
|
||||||
|
| **单例 MissionExecutorV3** | 一个任务实例全局可见,方便停止 |
|
||||||
|
| **蛇形路径镜像只取反 J1** | 用户要求:同一边只需镜像 J1 关节 |
|
||||||
|
| **QR 弹窗不可跳过** | 业务约束:机型不在报关单/超量必须人工介入 |
|
||||||
|
| **上传序号全局递增** | 连续编号便于后端核对 |
|
||||||
|
| **环境切换无需重启** | 运行时动态修改 config 变量 + 代理 URL |
|
||||||
|
|
||||||
|
### 10.2 已知约束
|
||||||
|
|
||||||
|
| 约束 | 影响 |
|
||||||
|
|------|------|
|
||||||
|
| **摄像头 device_index=4** | 固定的 Orbbec Gemini 设备号,不可修改 |
|
||||||
|
| **V4L2 设备独占** | 同时只能一个进程打开 /dev/video4 |
|
||||||
|
| **ROS 时钟漂移 ~5.5min** | 需检查 AGV 的 RTC/NTP 同步 |
|
||||||
|
| **机械臂精度 ±1.5°** | _wait_arm_ready 容差 2° 可能过严 |
|
||||||
|
| **AGV 重启自动切正式环境** | 无持久化配置方案 |
|
||||||
|
| **报关单数据依赖外部 API** | API 格式不稳定(裸数组 vs 包装对象) |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 附录 A:目录结构
|
||||||
|
|
||||||
|
```
|
||||||
|
agv_app/
|
||||||
|
├── app.py # Flask 主程序 (2132行)
|
||||||
|
├── config.py # 集中配置
|
||||||
|
├── templates/
|
||||||
|
│ ├── index.html # AGV 控制页
|
||||||
|
│ ├── setting.html # 设置页(网格/机型/报关单)
|
||||||
|
│ └── running.html # 任务运行页
|
||||||
|
├── static/
|
||||||
|
│ ├── css/style.css # 样式(深色主题)
|
||||||
|
│ └── js/
|
||||||
|
│ ├── app.js # 控制页逻辑
|
||||||
|
│ ├── setting.js # 设置页逻辑
|
||||||
|
│ ├── running.js # 运行页逻辑
|
||||||
|
│ └── vue3.global.prod.js # Vue 3 CDN
|
||||||
|
├── data/
|
||||||
|
│ ├── mission_config.json # 网格尺寸+点位坐标
|
||||||
|
│ ├── machines_config.json # 机器配置(正/背面)
|
||||||
|
│ ├── models_config.json # 机型配置(姿态组)
|
||||||
|
│ ├── qr_config.json # 二维码扫描姿态
|
||||||
|
│ └── map_config.json # 地图配置
|
||||||
|
├── utils/
|
||||||
|
│ ├── mission_executor.py # 任务执行器 V3 (1198行)
|
||||||
|
│ ├── agv_controller_ros2.py # AGV 运动控制 (216行)
|
||||||
|
│ ├── arm_client.py # 机械臂客户端 (170行)
|
||||||
|
│ ├── nav2_navigator.py # Nav2 导航器 (350行)
|
||||||
|
│ ├── qr_scanner.py # 二维码扫描 (170行)
|
||||||
|
│ └── image_uploader.py # 图片上传 (76行)
|
||||||
|
```
|
||||||
|
|
||||||
|
启动脚本位于仓库顶层 `scripts/`。LiDAR 时间戳修复脚本部署在 AGV 的
|
||||||
|
`/home/elephant/work/smart-inspection/scan_fixer/`,由 `scripts/prod-backend.sh` 调用。
|
||||||
|
|
||||||
|
## 附录 B:关键依赖
|
||||||
|
|
||||||
|
```
|
||||||
|
pyproject.toml # Python 依赖声明
|
||||||
|
uv.lock # 锁定版本
|
||||||
|
.python-version # Python 3.10
|
||||||
|
ffmpeg # 系统依赖,机械臂视频流
|
||||||
|
libzbar0 # 系统依赖,pyzbar 动态库
|
||||||
|
ROS2 Humble # 系统环境,提供 rclpy/nav2_simple_commander/geometry_msgs
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
> **文档维护**: 本文档随代码同步更新。关键变更请记录到 `memory/YYYY-MM-DD.md`。
|
||||||
@@ -0,0 +1,151 @@
|
|||||||
|
# AGV + 机械臂 移动拍摄平台 — 开发记录
|
||||||
|
|
||||||
|
> 汇总 2026年5-6月期间的所有修复记录和任务总结
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 一、running.html 显示修复 + 任务执行状态实时更新 (2026-05-29 13:10)
|
||||||
|
|
||||||
|
### 目标
|
||||||
|
修复运行页面两个 bug:
|
||||||
|
1. 模板中 `{{ }}` 显示为原始文本(Vue 未挂载)
|
||||||
|
2. 任务执行过程中状态不更新(始终显示"⏳等待")
|
||||||
|
|
||||||
|
### 根因分析
|
||||||
|
|
||||||
|
**问题1:`{{ }}` 原文显示**
|
||||||
|
- `running.js` 写有 `delimiters: ['[[', ']]']`,但 **Vue 3 已移除此选项**(被静默忽略)
|
||||||
|
- Vue 3 只认 `{{ }}`,但模板中混用了 `[[ ]]` 和 `{% raw %}{{ }}{% endraw %}`
|
||||||
|
- 残留的裸 `[[ ]]`(log、report、errorMsg 等)未被 Jinja2 处理,Vue 也因 delimiters 冲突不解析
|
||||||
|
- **修复**:删除 `delimiters` 行 → 全部改用 `{% raw %}{{ }}{% endraw %}` 包裹 Vue 表达式
|
||||||
|
|
||||||
|
**问题2:状态不更新**
|
||||||
|
- `api_mission_state()` 每次都从文件初始化 `point_status`/`machine_status` 为全 `"pending"`
|
||||||
|
- `mission_executor.py` 完全没有跟踪 `point_status` 和 `machine_status`
|
||||||
|
- **修复**:executor 添加状态跟踪 + app.py 从 executor.report 读取实时状态
|
||||||
|
|
||||||
|
### 修改的文件
|
||||||
|
|
||||||
|
| 文件 | 改动 |
|
||||||
|
|------|------|
|
||||||
|
| `running.js` | 删除 `delimiters: ['[[', ']]']` |
|
||||||
|
| `running.html` | 全部 `[[ ]]` → `{% raw %}{{ }}{% endraw %}`(14处) |
|
||||||
|
| `app.py` | `api_mission_state()` 从 `ex.report` 读取 `point_status`/`machine_status` |
|
||||||
|
| `mission_executor.py` | 初始化+实时更新 `point_status`(pending/active/done/skipped)和 `machine_status`(pending/active/completed) |
|
||||||
|
|
||||||
|
### 关键设计
|
||||||
|
|
||||||
|
**point_status 状态流转:**
|
||||||
|
- `pending` → `active`(开始导航到点位) → `done`(到达) → `skipped`(空位永不更新)
|
||||||
|
|
||||||
|
**machine_status 状态流转:**
|
||||||
|
- 初始化全 `pending`
|
||||||
|
- 正面扫码开始:`status=active, step=正面扫码`
|
||||||
|
- 扫码完成:`qr=done/skipped, qr_val=xxx, step=正面拍照`
|
||||||
|
- 正面拍照完成:`front=done/skipped, front_cnt++`
|
||||||
|
- 背面拍照开始:`step=背面拍照`
|
||||||
|
- 背面拍照完成:`back=done/skipped, back_cnt++, status=completed, step=完成`
|
||||||
|
|
||||||
|
### 部署状态
|
||||||
|
- 所有4个文件已通过 scp 部署到 `192.168.50.93`
|
||||||
|
- Flask 已重启(PID 3664)
|
||||||
|
- API 验证通过:`point_status` 和 `machine_status` 正常返回
|
||||||
|
- 本地文件已同步回 workspace
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 二、AGV 蛇形路径关节反转逻辑 (2026-05-29 13:49)
|
||||||
|
|
||||||
|
### 需求理解
|
||||||
|
|
||||||
|
蛇形路径行走时,AGV 在不同行到达点位时朝向相反:
|
||||||
|
- 偶数行(0,2,4...)点位 → AGV 从出发方向来 → 正面/背面朝向 = 标定朝向 → **不反转**
|
||||||
|
- 奇数行(1,3,5...)点位 → AGV 从对面来 → 正面/背面朝向 = 标定朝向的反面 → **反转所有关节角度**
|
||||||
|
|
||||||
|
### 修复内容
|
||||||
|
|
||||||
|
修改 `mission_executor.py`:
|
||||||
|
|
||||||
|
**1. `_shoot()` 新增 `machine_row` 参数**
|
||||||
|
```python
|
||||||
|
def _shoot(self, model, side, row, col, qr_value, machine_row=0):
|
||||||
|
invert = (machine_row % 2 == 1) # 奇数行=反转
|
||||||
|
if invert:
|
||||||
|
angles = [-a for a in angles] # 6个关节全部取反
|
||||||
|
```
|
||||||
|
调用处传入 `machine_row`(正面=pr,背面=pr-1)
|
||||||
|
|
||||||
|
**2. `_scan_qr_with_poses()` 新增 `machine_row` 参数**
|
||||||
|
```python
|
||||||
|
def _scan_qr_with_poses(self, qr_configs, machine_row=0):
|
||||||
|
invert = (machine_row % 2 == 1)
|
||||||
|
if invert:
|
||||||
|
angles = [-a for a in angles] # 二维码扫描时也反转
|
||||||
|
```
|
||||||
|
|
||||||
|
**3. 调用处传递 `machine_row`**
|
||||||
|
- `_scan_qr_with_poses(qr_configs, machine_row=pr)` — 正面扫码
|
||||||
|
- `_shoot(model, "front", ..., pr)` — 正面拍照
|
||||||
|
- `_shoot(model, "back", ..., pr-1)` — 背面拍照
|
||||||
|
|
||||||
|
### 部署状态
|
||||||
|
- Flask PID 20577,AGV IP 192.168.50.93
|
||||||
|
- 已通过语法检查 ✅ 已部署 ✅
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 三、修复删除机器姿态 404 错误 (2026-05-29)
|
||||||
|
|
||||||
|
### 问题描述
|
||||||
|
删除机器姿态时出现 404 错误:
|
||||||
|
```
|
||||||
|
/api/mission/poses/m_1778767289/pose_1778767312/undefined
|
||||||
|
```
|
||||||
|
URL 末尾出现 `undefined`,说明 `poseId` 参数丢失。
|
||||||
|
|
||||||
|
### 根因分析
|
||||||
|
JS 中存在两个同名方法 `deletePose`:
|
||||||
|
1. **机型姿态** (L457): `deletePose(modelId, poseId)` → 调用 `/api/models/...`
|
||||||
|
2. **机器姿态** (L776): `deletePose(machineId, side, poseId)` → 调用 `/api/mission/poses/...`
|
||||||
|
|
||||||
|
Vue 方法重载机制导致参数错位,`poseId` 变成 `undefined`。
|
||||||
|
|
||||||
|
### 修复方案
|
||||||
|
将机器姿态方法重命名为 `deleteMachinePose`,避免命名冲突。
|
||||||
|
|
||||||
|
### 修改文件
|
||||||
|
- `static/js/setting.js` L776: `deletePose` → `deleteMachinePose`
|
||||||
|
|
||||||
|
### 部署
|
||||||
|
- setting.js 已部署到 AGV
|
||||||
|
- setting.html 已部署到 AGV(版本号更新)
|
||||||
|
- 浏览器需刷新缓存 (Ctrl+F5)
|
||||||
|
|
||||||
|
### 待确认
|
||||||
|
- 模板中是否有调用 `deleteMachinePose` 的地方需同步修改
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 四、技术说明文档生成 (2026-06-17)
|
||||||
|
|
||||||
|
### 任务
|
||||||
|
为 AGV + 机械臂移动拍摄平台项目生成详细的技术说明文档
|
||||||
|
|
||||||
|
### 产出
|
||||||
|
- **文件**: `AGV_机械臂_技术说明文档.md` (888行, 39.5KB)
|
||||||
|
- **内容覆盖**:
|
||||||
|
1. 项目概述(业务目标、核心能力、技术栈)
|
||||||
|
2. 系统架构(架构图、核心文件清单)
|
||||||
|
3. 硬件环境与网络拓扑(设备清单、参数)
|
||||||
|
4. 核心模块详解(GlobalState、MissionExecutorV3、AGVController、ArmClient、Nav2Navigator、QRScanner、ImageUploader)
|
||||||
|
5. 通信协议(Flask↔前端、ROS2、TCP Socket、Java后端)
|
||||||
|
6. 完整API接口文档(11个分组、98个端点)
|
||||||
|
7. 任务执行流程(生命周期、QR扫描流程、拍照流程、错误处理)
|
||||||
|
8. 数据配置格式(4种JSON schema)
|
||||||
|
9. 部署与运维(启动流程、部署命令、常见问题)
|
||||||
|
10. 关键决策与约束(10项架构决策 + 6项已知约束)
|
||||||
|
|
||||||
|
### 数据来源
|
||||||
|
- 逐文件阅读了全部7个Python源文件(共~4312行代码)
|
||||||
|
- 读取了4个数据配置文件
|
||||||
|
- 结合记忆条目中的经验教训和已知问题
|
||||||
@@ -0,0 +1,3 @@
|
|||||||
|
{
|
||||||
|
"extends": "next/core-web-vitals"
|
||||||
|
}
|
||||||
@@ -0,0 +1,9 @@
|
|||||||
|
.next
|
||||||
|
node_modules
|
||||||
|
out
|
||||||
|
dist
|
||||||
|
.env*.local
|
||||||
|
npm-debug.log*
|
||||||
|
yarn-debug.log*
|
||||||
|
yarn-error.log*
|
||||||
|
pnpm-debug.log*
|
||||||
@@ -0,0 +1,9 @@
|
|||||||
|
# 海关智慧查验平台前端
|
||||||
|
|
||||||
|
这是从 `prototype/` 复刻并重构后的真实后端接入版本。原型目录保持只读,新代码集中在当前目录。
|
||||||
|
|
||||||
|
默认通过 Next.js rewrites 将 `/api/*` 与 `/photos/*` 转发到 Flask 后端 `http://127.0.0.1:5000`。如需改后端地址,可设置:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
BACKEND_URL=http://后端地址:5000 npm run dev
|
||||||
|
```
|
||||||
@@ -0,0 +1,19 @@
|
|||||||
|
/** @type {import('next').NextConfig} */
|
||||||
|
const backendUrl = process.env.BACKEND_URL || process.env.NEXT_PUBLIC_BACKEND_URL || 'http://127.0.0.1:5000';
|
||||||
|
|
||||||
|
const nextConfig = {
|
||||||
|
async rewrites() {
|
||||||
|
return [
|
||||||
|
{
|
||||||
|
source: '/api/:path*',
|
||||||
|
destination: `${backendUrl}/api/:path*`,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
source: '/photos/:path*',
|
||||||
|
destination: `${backendUrl}/photos/:path*`,
|
||||||
|
},
|
||||||
|
];
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
export default nextConfig;
|
||||||
Generated
+6458
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,33 @@
|
|||||||
|
{
|
||||||
|
"name": "public-frontend",
|
||||||
|
"version": "0.1.0",
|
||||||
|
"private": true,
|
||||||
|
"scripts": {
|
||||||
|
"dev": "next dev",
|
||||||
|
"build": "next build",
|
||||||
|
"start": "next start",
|
||||||
|
"lint": "next lint",
|
||||||
|
"typecheck": "tsc --noEmit"
|
||||||
|
},
|
||||||
|
"dependencies": {
|
||||||
|
"@ant-design/icons": "^6.2.5",
|
||||||
|
"@ant-design/nextjs-registry": "^1.3.0",
|
||||||
|
"antd": "^6.4.4",
|
||||||
|
"dayjs": "^1.11.21",
|
||||||
|
"html5-qrcode": "^2.3.8",
|
||||||
|
"next": "14.2.35",
|
||||||
|
"photoswipe": "^5.4.4",
|
||||||
|
"react": "^18",
|
||||||
|
"react-dom": "^18",
|
||||||
|
"react-photoswipe-gallery": "^4.1.2",
|
||||||
|
"zustand": "^5.0.14"
|
||||||
|
},
|
||||||
|
"devDependencies": {
|
||||||
|
"@types/node": "^20",
|
||||||
|
"@types/react": "^18",
|
||||||
|
"@types/react-dom": "^18",
|
||||||
|
"eslint": "^8",
|
||||||
|
"eslint-config-next": "14.2.35",
|
||||||
|
"typescript": "^5"
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,243 @@
|
|||||||
|
'use client';
|
||||||
|
|
||||||
|
import React, { useEffect, useMemo, useState } from 'react';
|
||||||
|
import { Alert, Button, Card, Col, DatePicker, Form, Input, Row, Select, Space, Table, message } from 'antd';
|
||||||
|
import type { ColumnsType } from 'antd/es/table';
|
||||||
|
import { PlayCircleOutlined, SearchOutlined } from '@ant-design/icons';
|
||||||
|
import { useRouter } from 'next/navigation';
|
||||||
|
import dayjs from 'dayjs';
|
||||||
|
import { Breadcrumb } from '@/components/Breadcrumb';
|
||||||
|
import { StatusBadge } from '@/components/StatusBadge';
|
||||||
|
import { BackendApi } from '@/services/backendApi';
|
||||||
|
import { useAppStore } from '@/store/useAppStore';
|
||||||
|
import type { CustomsDeclaration, InspectionItem } from '@/types';
|
||||||
|
|
||||||
|
const { RangePicker } = DatePicker;
|
||||||
|
|
||||||
|
export default function CustomsPage() {
|
||||||
|
const router = useRouter();
|
||||||
|
const [data, setData] = useState<CustomsDeclaration[]>([]);
|
||||||
|
const [filteredData, setFilteredData] = useState<CustomsDeclaration[]>([]);
|
||||||
|
const [loading, setLoading] = useState(true);
|
||||||
|
const [startingId, setStartingId] = useState<string | null>(null);
|
||||||
|
const [errorMessage, setErrorMessage] = useState('');
|
||||||
|
const [messageApi, contextHolder] = message.useMessage();
|
||||||
|
const [form] = Form.useForm();
|
||||||
|
const { setSelectedCustoms, setInspection } = useAppStore();
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
let isMounted = true;
|
||||||
|
|
||||||
|
const loadCustomsList = async () => {
|
||||||
|
try {
|
||||||
|
setLoading(true);
|
||||||
|
setErrorMessage('');
|
||||||
|
const customsList = await BackendApi.getCustomsList(1, 100);
|
||||||
|
if (!isMounted) return;
|
||||||
|
setData(customsList);
|
||||||
|
setFilteredData(customsList);
|
||||||
|
} catch (error) {
|
||||||
|
if (!isMounted) return;
|
||||||
|
setErrorMessage(error instanceof Error ? error.message : '报关单列表加载失败,请稍后重试');
|
||||||
|
} finally {
|
||||||
|
if (isMounted) {
|
||||||
|
setLoading(false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
loadCustomsList();
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
isMounted = false;
|
||||||
|
};
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const handleSearch = () => {
|
||||||
|
const values = form.getFieldsValue();
|
||||||
|
const keyword = values.searchText?.trim().toLowerCase() || '';
|
||||||
|
const status = values.statusFilter || 'all';
|
||||||
|
const dateRange = values.dateRange;
|
||||||
|
|
||||||
|
const nextData = data.filter((item) => {
|
||||||
|
const matchesStatus = status === 'all' || item.status === status;
|
||||||
|
const matchesKeyword = !keyword || item.customsName.toLowerCase().includes(keyword) || item.customsId.toLowerCase().includes(keyword);
|
||||||
|
const createdAt = dayjs(item.createdAt);
|
||||||
|
const matchesDateRange = !dateRange?.[0] || !dateRange?.[1] || !createdAt.isValid()
|
||||||
|
|| (createdAt.isAfter(dateRange[0].startOf('day')) && createdAt.isBefore(dateRange[1].endOf('day')));
|
||||||
|
|
||||||
|
return matchesStatus && matchesKeyword && matchesDateRange;
|
||||||
|
});
|
||||||
|
|
||||||
|
setFilteredData(nextData);
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleReset = () => {
|
||||||
|
form.resetFields();
|
||||||
|
setFilteredData(data);
|
||||||
|
};
|
||||||
|
|
||||||
|
const loadExpandedItems = async (record: CustomsDeclaration): Promise<CustomsDeclaration> => {
|
||||||
|
if (record.items.length) {
|
||||||
|
return record;
|
||||||
|
}
|
||||||
|
const items = await BackendApi.getCustomsMachines(record.id);
|
||||||
|
const nextRecord = {
|
||||||
|
...record,
|
||||||
|
items,
|
||||||
|
machineCount: record.machineCount || items.reduce((sum, item) => sum + item.quantify, 0),
|
||||||
|
};
|
||||||
|
setData((current) => current.map((item) => (item.id === record.id ? nextRecord : item)));
|
||||||
|
setFilteredData((current) => current.map((item) => (item.id === record.id ? nextRecord : item)));
|
||||||
|
return nextRecord;
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleStartInspection = async (record: CustomsDeclaration) => {
|
||||||
|
setStartingId(record.id);
|
||||||
|
try {
|
||||||
|
const nextRecord = await loadExpandedItems(record);
|
||||||
|
const inspection = await BackendApi.startCustomsInspection(nextRecord);
|
||||||
|
setSelectedCustoms(nextRecord);
|
||||||
|
setInspection(inspection);
|
||||||
|
messageApi.success('查验已开始');
|
||||||
|
router.push(`/inspection?customsId=${encodeURIComponent(nextRecord.id)}`);
|
||||||
|
} catch (error) {
|
||||||
|
messageApi.error(error instanceof Error ? error.message : '开始查验失败');
|
||||||
|
} finally {
|
||||||
|
setStartingId(null);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const itemColumns: ColumnsType<InspectionItem> = useMemo(() => [
|
||||||
|
{ title: '料号', dataIndex: 'inventoryCode', key: 'inventoryCode' },
|
||||||
|
{ title: '品名', dataIndex: 'inventoryName', key: 'inventoryName' },
|
||||||
|
{ title: '规格', dataIndex: 'spec', key: 'spec' },
|
||||||
|
{ title: '总数', dataIndex: 'quantify', key: 'quantify' },
|
||||||
|
{ title: '已查验', dataIndex: 'inspected', key: 'inspected' },
|
||||||
|
], []);
|
||||||
|
|
||||||
|
const expandedRowRender = (record: CustomsDeclaration) => (
|
||||||
|
<Table
|
||||||
|
columns={itemColumns}
|
||||||
|
dataSource={record.items}
|
||||||
|
pagination={false}
|
||||||
|
size="small"
|
||||||
|
rowKey={(item) => item.inventoryCode}
|
||||||
|
locale={{ emptyText: '展开后端机器列表为空,或暂未加载' }}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
|
||||||
|
const columns: ColumnsType<CustomsDeclaration> = [
|
||||||
|
{
|
||||||
|
title: '报关单号',
|
||||||
|
dataIndex: 'customsName',
|
||||||
|
key: 'customsName',
|
||||||
|
render: (text: string) => <b>{text}</b>,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: '状态',
|
||||||
|
dataIndex: 'status',
|
||||||
|
key: 'status',
|
||||||
|
render: (status: CustomsDeclaration['status']) => <StatusBadge status={status} />,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: '机器总数',
|
||||||
|
dataIndex: 'machineCount',
|
||||||
|
key: 'machineCount',
|
||||||
|
render: (count: number) => (count ? `${count} 台` : '-'),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: '创建时间',
|
||||||
|
dataIndex: 'createdAt',
|
||||||
|
key: 'createdAt',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: '操作',
|
||||||
|
key: 'action',
|
||||||
|
render: (_, record) => (
|
||||||
|
<Space size="middle">
|
||||||
|
<Button
|
||||||
|
type="primary"
|
||||||
|
icon={<PlayCircleOutlined />}
|
||||||
|
loading={startingId === record.id}
|
||||||
|
onClick={() => handleStartInspection(record)}
|
||||||
|
>
|
||||||
|
开始查验
|
||||||
|
</Button>
|
||||||
|
</Space>
|
||||||
|
),
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div>
|
||||||
|
{contextHolder}
|
||||||
|
<Breadcrumb />
|
||||||
|
|
||||||
|
{errorMessage && (
|
||||||
|
<Alert type="error" message={errorMessage} showIcon style={{ marginBottom: 16 }} />
|
||||||
|
)}
|
||||||
|
|
||||||
|
<Card title="筛选条件" style={{ marginBottom: 24 }}>
|
||||||
|
<Form form={form} layout="inline">
|
||||||
|
<Row gutter={24} align="middle">
|
||||||
|
<Col>
|
||||||
|
<Form.Item label="状态" name="statusFilter" initialValue="all">
|
||||||
|
<Select
|
||||||
|
style={{ width: 120 }}
|
||||||
|
options={[
|
||||||
|
{ value: 'all', label: '全部' },
|
||||||
|
{ value: 'pending', label: '待查验' },
|
||||||
|
{ value: 'inspecting', label: '查验中' },
|
||||||
|
{ value: 'released', label: '已放行' },
|
||||||
|
{ value: 'abnormal', label: '异常' },
|
||||||
|
]}
|
||||||
|
/>
|
||||||
|
</Form.Item>
|
||||||
|
</Col>
|
||||||
|
<Col>
|
||||||
|
<Form.Item label="日期范围" name="dateRange">
|
||||||
|
<RangePicker />
|
||||||
|
</Form.Item>
|
||||||
|
</Col>
|
||||||
|
<Col>
|
||||||
|
<Form.Item name="searchText">
|
||||||
|
<Input
|
||||||
|
placeholder="搜索报关单号..."
|
||||||
|
prefix={<SearchOutlined />}
|
||||||
|
onPressEnter={handleSearch}
|
||||||
|
style={{ width: 250 }}
|
||||||
|
/>
|
||||||
|
</Form.Item>
|
||||||
|
</Col>
|
||||||
|
<Col>
|
||||||
|
<Space>
|
||||||
|
<Button type="primary" onClick={handleSearch}>查询</Button>
|
||||||
|
<Button onClick={handleReset}>重置</Button>
|
||||||
|
</Space>
|
||||||
|
</Col>
|
||||||
|
</Row>
|
||||||
|
</Form>
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
<Card title="报关单列表" styles={{ body: { padding: 0 } }}>
|
||||||
|
<Table
|
||||||
|
dataSource={filteredData}
|
||||||
|
loading={loading}
|
||||||
|
rowKey="id"
|
||||||
|
columns={columns}
|
||||||
|
expandable={{
|
||||||
|
expandedRowRender,
|
||||||
|
onExpand: (expanded, record) => {
|
||||||
|
if (expanded && !record.items.length) {
|
||||||
|
loadExpandedItems(record).catch((error) => {
|
||||||
|
messageApi.error(error instanceof Error ? error.message : '机器列表加载失败');
|
||||||
|
});
|
||||||
|
}
|
||||||
|
},
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</Card>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
Binary file not shown.
|
After Width: | Height: | Size: 25 KiB |
Binary file not shown.
Binary file not shown.
@@ -0,0 +1,64 @@
|
|||||||
|
:root {
|
||||||
|
--color-border-light: #f0f0f0;
|
||||||
|
}
|
||||||
|
|
||||||
|
html,
|
||||||
|
body {
|
||||||
|
max-width: 100vw;
|
||||||
|
height: 100vh;
|
||||||
|
overflow-x: hidden;
|
||||||
|
}
|
||||||
|
|
||||||
|
body {
|
||||||
|
color: rgba(0, 0, 0, 0.88);
|
||||||
|
background: #f0f2f5;
|
||||||
|
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, "Helvetica Neue", Arial, sans-serif;
|
||||||
|
-webkit-font-smoothing: antialiased;
|
||||||
|
-moz-osx-font-smoothing: grayscale;
|
||||||
|
}
|
||||||
|
|
||||||
|
* {
|
||||||
|
box-sizing: border-box;
|
||||||
|
padding: 0;
|
||||||
|
margin: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
a {
|
||||||
|
color: inherit;
|
||||||
|
text-decoration: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.appBody {
|
||||||
|
height: 100vh;
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
|
||||||
|
.antAppRoot {
|
||||||
|
height: 100%;
|
||||||
|
}
|
||||||
|
|
||||||
|
.appShell {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
height: 100vh;
|
||||||
|
}
|
||||||
|
|
||||||
|
.appMain {
|
||||||
|
flex: 1;
|
||||||
|
padding: 24px;
|
||||||
|
overflow-y: auto;
|
||||||
|
background: #f0f2f5;
|
||||||
|
}
|
||||||
|
|
||||||
|
.cameraFrameImage {
|
||||||
|
display: block;
|
||||||
|
width: 100%;
|
||||||
|
height: 100%;
|
||||||
|
object-fit: cover;
|
||||||
|
}
|
||||||
|
|
||||||
|
.scanCorner {
|
||||||
|
position: absolute;
|
||||||
|
width: 20px;
|
||||||
|
height: 20px;
|
||||||
|
}
|
||||||
@@ -0,0 +1,582 @@
|
|||||||
|
'use client';
|
||||||
|
|
||||||
|
import React, { Suspense, useEffect, useMemo, useRef, useState } from 'react';
|
||||||
|
import {
|
||||||
|
Badge,
|
||||||
|
Button,
|
||||||
|
Card,
|
||||||
|
Col,
|
||||||
|
Empty,
|
||||||
|
Flex,
|
||||||
|
Input,
|
||||||
|
Modal,
|
||||||
|
Progress,
|
||||||
|
Row,
|
||||||
|
Select,
|
||||||
|
Space,
|
||||||
|
Spin,
|
||||||
|
Table,
|
||||||
|
Tag,
|
||||||
|
Timeline,
|
||||||
|
Typography,
|
||||||
|
message,
|
||||||
|
theme,
|
||||||
|
} from 'antd';
|
||||||
|
import type { ColumnsType } from 'antd/es/table';
|
||||||
|
import {
|
||||||
|
PauseCircleFilled,
|
||||||
|
PauseCircleOutlined,
|
||||||
|
PlayCircleOutlined,
|
||||||
|
ReloadOutlined,
|
||||||
|
StopOutlined,
|
||||||
|
} from '@ant-design/icons';
|
||||||
|
import { useRouter, useSearchParams } from 'next/navigation';
|
||||||
|
import { Breadcrumb } from '@/components/Breadcrumb';
|
||||||
|
import { CameraFrame } from '@/components/CameraFrame';
|
||||||
|
import { BackendApi } from '@/services/backendApi';
|
||||||
|
import { useAppStore } from '@/store/useAppStore';
|
||||||
|
import type { ActivityItem, CameraInfo, CustomsDeclaration, InspectionIssue, InspectionItem, MissionRuntimeState } from '@/types';
|
||||||
|
|
||||||
|
const { Text } = Typography;
|
||||||
|
const { TextArea } = Input;
|
||||||
|
const LOG_AUTO_SCROLL_THRESHOLD = 48;
|
||||||
|
|
||||||
|
interface ProgressItem extends InspectionItem {
|
||||||
|
currentInspected: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function InspectionPage() {
|
||||||
|
return (
|
||||||
|
<Suspense
|
||||||
|
fallback={
|
||||||
|
<Flex vertical align="center" justify="center" style={{ padding: 48, height: '100vh' }}>
|
||||||
|
<Spin tip="正在加载查验任务..." />
|
||||||
|
</Flex>
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<InspectionContent />
|
||||||
|
</Suspense>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function InspectionContent() {
|
||||||
|
const router = useRouter();
|
||||||
|
const searchParams = useSearchParams();
|
||||||
|
const customsId = searchParams.get('customsId');
|
||||||
|
const { selectedCustoms, setSelectedCustoms, setInspection } = useAppStore();
|
||||||
|
const [customsList, setCustomsList] = useState<CustomsDeclaration[]>([]);
|
||||||
|
const [currentCustoms, setCurrentCustoms] = useState<CustomsDeclaration | null>(null);
|
||||||
|
const [status, setStatus] = useState<MissionRuntimeState>('idle');
|
||||||
|
const [logs, setLogs] = useState<ActivityItem[]>([]);
|
||||||
|
const [progressData, setProgressData] = useState<ProgressItem[]>([]);
|
||||||
|
const [issues, setIssues] = useState<InspectionIssue[]>([]);
|
||||||
|
const [cameras, setCameras] = useState<CameraInfo[]>([]);
|
||||||
|
const [currentOverviewCamera, setCurrentOverviewCamera] = useState<string>('');
|
||||||
|
const [isPauseModalVisible, setIsPauseModalVisible] = useState(false);
|
||||||
|
const [pauseReason, setPauseReason] = useState('');
|
||||||
|
const [loadingCustoms, setLoadingCustoms] = useState(true);
|
||||||
|
const [loadingList, setLoadingList] = useState(false);
|
||||||
|
const [operationLoading, setOperationLoading] = useState(false);
|
||||||
|
const [messageApi, contextHolder] = message.useMessage();
|
||||||
|
const { token } = theme.useToken();
|
||||||
|
const logsContainerRef = useRef<HTMLDivElement>(null);
|
||||||
|
const [isUserNearBottom, setIsUserNearBottom] = useState(true);
|
||||||
|
|
||||||
|
const refreshRuntime = async () => {
|
||||||
|
const [missionState, currentInspection, missionLogs, nextIssues] = await Promise.all([
|
||||||
|
BackendApi.getMissionState(),
|
||||||
|
BackendApi.getCurrentInspection().catch(() => null),
|
||||||
|
BackendApi.getMissionLogs().catch(() => []),
|
||||||
|
BackendApi.getInspectionIssues().catch(() => []),
|
||||||
|
]);
|
||||||
|
const inspection = missionState.inspection ?? currentInspection;
|
||||||
|
const runtimeStatus = missionState.state === 'running' || missionState.state === 'setting'
|
||||||
|
? 'running'
|
||||||
|
: missionState.state === 'paused'
|
||||||
|
? 'paused'
|
||||||
|
: inspection
|
||||||
|
? 'idle'
|
||||||
|
: 'idle';
|
||||||
|
|
||||||
|
setStatus(runtimeStatus);
|
||||||
|
setLogs(missionLogs);
|
||||||
|
setIssues(nextIssues);
|
||||||
|
|
||||||
|
if (inspection) {
|
||||||
|
setInspection(inspection);
|
||||||
|
setProgressData(inspection.items.map((item) => ({ ...item, currentInspected: item.inspected })));
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
let isMounted = true;
|
||||||
|
|
||||||
|
const loadBaseData = async () => {
|
||||||
|
setLoadingList(true);
|
||||||
|
try {
|
||||||
|
const [list, cameraList] = await Promise.all([
|
||||||
|
BackendApi.getCustomsList(1, 100),
|
||||||
|
BackendApi.getCameras(),
|
||||||
|
]);
|
||||||
|
if (!isMounted) return;
|
||||||
|
setCustomsList(list);
|
||||||
|
setCameras(cameraList);
|
||||||
|
const overviews = cameraList.filter((camera) => camera.category === 'overview');
|
||||||
|
if (overviews.length > 0) {
|
||||||
|
setCurrentOverviewCamera(overviews[0].id);
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
if (isMounted) {
|
||||||
|
messageApi.error(error instanceof Error ? error.message : '基础数据加载失败');
|
||||||
|
}
|
||||||
|
} finally {
|
||||||
|
if (isMounted) {
|
||||||
|
setLoadingList(false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
loadBaseData();
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
isMounted = false;
|
||||||
|
};
|
||||||
|
}, [messageApi]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
let isMounted = true;
|
||||||
|
|
||||||
|
const loadInspectionCustoms = async () => {
|
||||||
|
setLoadingCustoms(true);
|
||||||
|
try {
|
||||||
|
if (customsId) {
|
||||||
|
const cachedCustoms = selectedCustoms?.id === customsId || selectedCustoms?.customsId === customsId ? selectedCustoms : null;
|
||||||
|
const customs = cachedCustoms ?? await BackendApi.getCustomsById(customsId);
|
||||||
|
|
||||||
|
if (!isMounted) return;
|
||||||
|
setCurrentCustoms(customs);
|
||||||
|
setSelectedCustoms(customs);
|
||||||
|
if (customs) {
|
||||||
|
setProgressData(customs.items.map((item) => ({ ...item, currentInspected: item.inspected })));
|
||||||
|
}
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const currentInspection = await BackendApi.getCurrentInspection();
|
||||||
|
if (!isMounted) return;
|
||||||
|
if (currentInspection) {
|
||||||
|
setInspection(currentInspection);
|
||||||
|
setCurrentCustoms({
|
||||||
|
id: currentInspection.customsId,
|
||||||
|
customsId: currentInspection.customsId,
|
||||||
|
customsName: currentInspection.customsName,
|
||||||
|
status: 'inspecting',
|
||||||
|
machineCount: currentInspection.items.reduce((sum, item) => sum + item.quantify, 0),
|
||||||
|
createdAt: '-',
|
||||||
|
items: currentInspection.items,
|
||||||
|
});
|
||||||
|
setProgressData(currentInspection.items.map((item) => ({ ...item, currentInspected: item.inspected })));
|
||||||
|
} else {
|
||||||
|
setCurrentCustoms(selectedCustoms);
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
if (isMounted) {
|
||||||
|
setCurrentCustoms(null);
|
||||||
|
}
|
||||||
|
} finally {
|
||||||
|
if (isMounted) {
|
||||||
|
setLoadingCustoms(false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
loadInspectionCustoms();
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
isMounted = false;
|
||||||
|
};
|
||||||
|
}, [customsId, selectedCustoms, setInspection, setSelectedCustoms]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const loadRuntime = async () => {
|
||||||
|
const [missionState, currentInspection, missionLogs, nextIssues] = await Promise.all([
|
||||||
|
BackendApi.getMissionState(),
|
||||||
|
BackendApi.getCurrentInspection().catch(() => null),
|
||||||
|
BackendApi.getMissionLogs().catch(() => []),
|
||||||
|
BackendApi.getInspectionIssues().catch(() => []),
|
||||||
|
]);
|
||||||
|
const inspection = missionState.inspection ?? currentInspection;
|
||||||
|
const runtimeStatus = missionState.state === 'running' || missionState.state === 'setting'
|
||||||
|
? 'running'
|
||||||
|
: missionState.state === 'paused'
|
||||||
|
? 'paused'
|
||||||
|
: inspection
|
||||||
|
? 'idle'
|
||||||
|
: 'idle';
|
||||||
|
|
||||||
|
setStatus(runtimeStatus);
|
||||||
|
setLogs(missionLogs);
|
||||||
|
setIssues(nextIssues);
|
||||||
|
|
||||||
|
if (inspection) {
|
||||||
|
setInspection(inspection);
|
||||||
|
setProgressData(inspection.items.map((item) => ({ ...item, currentInspected: item.inspected })));
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
loadRuntime().catch(() => undefined);
|
||||||
|
const timer = window.setInterval(() => {
|
||||||
|
loadRuntime().catch(() => undefined);
|
||||||
|
}, 3000);
|
||||||
|
|
||||||
|
return () => window.clearInterval(timer);
|
||||||
|
}, [setInspection]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const container = logsContainerRef.current;
|
||||||
|
if (container && isUserNearBottom) {
|
||||||
|
container.scrollTop = container.scrollHeight;
|
||||||
|
}
|
||||||
|
}, [logs, isUserNearBottom]);
|
||||||
|
|
||||||
|
const handleLogsScroll = () => {
|
||||||
|
const container = logsContainerRef.current;
|
||||||
|
if (!container) return;
|
||||||
|
|
||||||
|
const distanceToBottom = container.scrollHeight - container.scrollTop - container.clientHeight;
|
||||||
|
setIsUserNearBottom(distanceToBottom <= LOG_AUTO_SCROLL_THRESHOLD);
|
||||||
|
};
|
||||||
|
|
||||||
|
const calculateTotalProgress = () => {
|
||||||
|
if (!progressData.length) return 0;
|
||||||
|
const total = progressData.reduce((sum, item) => sum + item.quantify, 0);
|
||||||
|
const inspected = progressData.reduce((sum, item) => sum + item.currentInspected, 0);
|
||||||
|
return total > 0 ? Math.round((inspected / total) * 100) : 0;
|
||||||
|
};
|
||||||
|
|
||||||
|
const overviewCameras = cameras.filter((camera) => camera.category === 'overview');
|
||||||
|
const agvCamera = cameras.find((camera) => camera.category === 'agv');
|
||||||
|
const operationCamera = cameras.find((camera) => camera.category === 'operation');
|
||||||
|
const selectedOverviewCamera = overviewCameras.find((camera) => camera.id === currentOverviewCamera) || overviewCameras[0];
|
||||||
|
|
||||||
|
const selectOptions = useMemo(() => customsList.map((item) => ({
|
||||||
|
value: item.id,
|
||||||
|
label: `${item.customsName} - ${item.status === 'pending' ? '待查验' : item.status === 'inspecting' ? '查验中' : item.status === 'released' ? '已放行' : '异常'}`,
|
||||||
|
})), [customsList]);
|
||||||
|
|
||||||
|
const handleStart = async () => {
|
||||||
|
if (!currentCustoms) {
|
||||||
|
messageApi.warning('请先选择报关单');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
setOperationLoading(true);
|
||||||
|
try {
|
||||||
|
const inspection = await BackendApi.startCustomsInspection(currentCustoms);
|
||||||
|
setInspection(inspection);
|
||||||
|
await BackendApi.startMission();
|
||||||
|
setStatus('running');
|
||||||
|
await refreshRuntime();
|
||||||
|
messageApi.success('自动化查验作业已启动');
|
||||||
|
} catch (error) {
|
||||||
|
messageApi.error(error instanceof Error ? error.message : '启动查验失败');
|
||||||
|
} finally {
|
||||||
|
setOperationLoading(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const confirmPause = async () => {
|
||||||
|
setOperationLoading(true);
|
||||||
|
try {
|
||||||
|
await BackendApi.pauseMission();
|
||||||
|
setStatus('paused');
|
||||||
|
setLogs((current) => [
|
||||||
|
...current,
|
||||||
|
{ id: `pause-${Date.now()}`, time: new Date().toLocaleTimeString(), type: 'warning', message: `查验已暂停。原因:${pauseReason || '未填写'}` },
|
||||||
|
]);
|
||||||
|
setIsPauseModalVisible(false);
|
||||||
|
setPauseReason('');
|
||||||
|
} catch (error) {
|
||||||
|
messageApi.error(error instanceof Error ? error.message : '暂停查验失败');
|
||||||
|
} finally {
|
||||||
|
setOperationLoading(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleResume = async () => {
|
||||||
|
setOperationLoading(true);
|
||||||
|
try {
|
||||||
|
await BackendApi.resumeMission();
|
||||||
|
setStatus('running');
|
||||||
|
await refreshRuntime();
|
||||||
|
messageApi.success('查验已继续');
|
||||||
|
} catch (error) {
|
||||||
|
messageApi.error(error instanceof Error ? error.message : '继续查验失败');
|
||||||
|
} finally {
|
||||||
|
setOperationLoading(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleEnd = () => {
|
||||||
|
Modal.confirm({
|
||||||
|
title: '确认结束查验?',
|
||||||
|
content: '结束查验会停止当前自动任务并清空当前报关单查验状态。',
|
||||||
|
okText: '确认结束',
|
||||||
|
cancelText: '取消',
|
||||||
|
onOk: async () => {
|
||||||
|
setOperationLoading(true);
|
||||||
|
try {
|
||||||
|
await BackendApi.stopMission();
|
||||||
|
await BackendApi.endCustomsInspection();
|
||||||
|
setStatus('completed');
|
||||||
|
setInspection(null);
|
||||||
|
await refreshRuntime();
|
||||||
|
messageApi.success('查验已结束');
|
||||||
|
} catch (error) {
|
||||||
|
messageApi.error(error instanceof Error ? error.message : '结束查验失败');
|
||||||
|
} finally {
|
||||||
|
setOperationLoading(false);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
const issueColumns: ColumnsType<InspectionIssue> = [
|
||||||
|
{
|
||||||
|
title: '时间',
|
||||||
|
dataIndex: 'time',
|
||||||
|
key: 'time',
|
||||||
|
width: 90,
|
||||||
|
render: (text: string) => <Text style={{ fontSize: 13 }}>{text}</Text>,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: '问题描述',
|
||||||
|
dataIndex: 'description',
|
||||||
|
key: 'description',
|
||||||
|
render: (text: string, record) => (
|
||||||
|
<Space>
|
||||||
|
<Badge status={record.severity === 'error' ? 'error' : 'warning'} />
|
||||||
|
<Text style={{ fontSize: 13 }}>{text}</Text>
|
||||||
|
</Space>
|
||||||
|
),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: '状态',
|
||||||
|
dataIndex: 'status',
|
||||||
|
key: 'status',
|
||||||
|
width: 80,
|
||||||
|
render: (issueStatus: InspectionIssue['status']) => {
|
||||||
|
const info = {
|
||||||
|
pending: { color: 'red', text: '待处理' },
|
||||||
|
disposed: { color: 'green', text: '已处置' },
|
||||||
|
cancelled: { color: 'default', text: '已取消' },
|
||||||
|
}[issueStatus];
|
||||||
|
return <Tag color={info.color} style={{ margin: 0 }}>{info.text}</Tag>;
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: '操作',
|
||||||
|
key: 'action',
|
||||||
|
width: 120,
|
||||||
|
render: (_, record) => (
|
||||||
|
record.status === 'pending' ? (
|
||||||
|
<Space size="small">
|
||||||
|
<Button size="small" type="primary" onClick={() => setIssues((current) => current.map((issue) => issue.id === record.id ? { ...issue, status: 'disposed' } : issue))}>已处置</Button>
|
||||||
|
<Button size="small" onClick={() => setIssues((current) => current.map((issue) => issue.id === record.id ? { ...issue, status: 'cancelled' } : issue))}>取消</Button>
|
||||||
|
</Space>
|
||||||
|
) : null
|
||||||
|
),
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
if (loadingCustoms) {
|
||||||
|
return (
|
||||||
|
<Flex vertical align="center" justify="center" style={{ padding: 48, height: '100vh' }}>
|
||||||
|
<Spin tip="正在加载查验任务..." />
|
||||||
|
</Flex>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div style={{ display: 'flex', flexDirection: 'column', height: 'calc(100vh - 64px)' }}>
|
||||||
|
{contextHolder}
|
||||||
|
<Flex align="center" justify="space-between" style={{ padding: '0 24px', margin: '16px 0' }}>
|
||||||
|
<Breadcrumb />
|
||||||
|
<Flex align="center" gap="large">
|
||||||
|
{currentCustoms && (
|
||||||
|
<Badge
|
||||||
|
status={status === 'running' ? 'processing' : status === 'idle' ? 'default' : status === 'paused' ? 'warning' : 'success'}
|
||||||
|
text={<Text strong>{status === 'running' ? '作业中' : status === 'idle' ? '待作业' : status === 'paused' ? '已暂停' : '已完成'}</Text>}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
<Select
|
||||||
|
showSearch
|
||||||
|
placeholder="搜索并选择报关单..."
|
||||||
|
style={{ width: 240 }}
|
||||||
|
loading={loadingList}
|
||||||
|
optionFilterProp="label"
|
||||||
|
options={selectOptions}
|
||||||
|
value={currentCustoms?.id}
|
||||||
|
onChange={(value) => router.push(`/inspection?customsId=${encodeURIComponent(value)}`)}
|
||||||
|
/>
|
||||||
|
</Flex>
|
||||||
|
</Flex>
|
||||||
|
|
||||||
|
<Row gutter={24} style={{ flex: 1, minHeight: 0, margin: '0 24px 24px 24px' }}>
|
||||||
|
<Col span={16} style={{ display: 'flex', flexDirection: 'column', height: '100%' }}>
|
||||||
|
<div style={{ flex: 1, minHeight: 0, display: 'flex', gap: 16, marginBottom: 16 }}>
|
||||||
|
<div style={{ flex: 1, display: 'flex', flexDirection: 'column', gap: 16 }}>
|
||||||
|
<Card
|
||||||
|
size="small"
|
||||||
|
title={<Text strong>AGV 主视角 ({agvCamera?.name || '未知'})</Text>}
|
||||||
|
styles={{ body: { padding: 0, flex: 1, display: 'flex', flexDirection: 'column' } }}
|
||||||
|
style={{ flex: 1, display: 'flex', flexDirection: 'column', overflow: 'hidden' }}
|
||||||
|
>
|
||||||
|
<CameraFrame camera={agvCamera} active={status === 'running'} />
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
<Card
|
||||||
|
size="small"
|
||||||
|
title={<Text strong>作业视角 ({operationCamera?.name || '未知'})</Text>}
|
||||||
|
styles={{ body: { padding: 0, flex: 1, display: 'flex', flexDirection: 'column' } }}
|
||||||
|
style={{ flex: 1, display: 'flex', flexDirection: 'column', overflow: 'hidden' }}
|
||||||
|
>
|
||||||
|
<CameraFrame camera={operationCamera} active={status === 'running'} />
|
||||||
|
</Card>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<Card
|
||||||
|
size="small"
|
||||||
|
title={
|
||||||
|
<Flex justify="space-between" align="center">
|
||||||
|
<Text strong>监控摄像头 ({selectedOverviewCamera?.name || '未知'})</Text>
|
||||||
|
<Button
|
||||||
|
size="small"
|
||||||
|
onClick={() => {
|
||||||
|
if (overviewCameras.length > 0) {
|
||||||
|
const currentIndex = overviewCameras.findIndex((camera) => camera.id === currentOverviewCamera);
|
||||||
|
const nextIndex = (currentIndex + 1) % overviewCameras.length;
|
||||||
|
setCurrentOverviewCamera(overviewCameras[nextIndex]?.id || currentOverviewCamera);
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
切换
|
||||||
|
</Button>
|
||||||
|
</Flex>
|
||||||
|
}
|
||||||
|
styles={{ body: { padding: 0, flex: 1, display: 'flex', flexDirection: 'column' } }}
|
||||||
|
style={{ flex: 2, display: 'flex', flexDirection: 'column', overflow: 'hidden' }}
|
||||||
|
>
|
||||||
|
<CameraFrame camera={selectedOverviewCamera} active={status === 'running'} />
|
||||||
|
</Card>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<Card size="small" style={{ flexShrink: 0 }}>
|
||||||
|
<Flex justify="center" gap="middle">
|
||||||
|
{status === 'idle' ? (
|
||||||
|
<Button type="primary" size="large" icon={<PlayCircleOutlined />} onClick={handleStart} loading={operationLoading} style={{ width: 140 }}>
|
||||||
|
开始查验
|
||||||
|
</Button>
|
||||||
|
) : status === 'paused' ? (
|
||||||
|
<Button type="primary" size="large" icon={<PlayCircleOutlined />} onClick={handleResume} loading={operationLoading} style={{ width: 140 }}>
|
||||||
|
继续查验
|
||||||
|
</Button>
|
||||||
|
) : status === 'running' ? (
|
||||||
|
<Button type="primary" danger size="large" icon={<PauseCircleOutlined />} onClick={() => setIsPauseModalVisible(true)} loading={operationLoading} style={{ width: 140 }}>
|
||||||
|
暂停查验
|
||||||
|
</Button>
|
||||||
|
) : null}
|
||||||
|
<Button size="large" icon={<ReloadOutlined />} disabled={status === 'completed'} onClick={() => refreshRuntime()}>刷新</Button>
|
||||||
|
<Button danger size="large" icon={<StopOutlined />} onClick={handleEnd} disabled={status === 'completed' || status === 'idle'}>结束</Button>
|
||||||
|
</Flex>
|
||||||
|
</Card>
|
||||||
|
</Col>
|
||||||
|
|
||||||
|
<Col span={8} style={{ display: 'flex', flexDirection: 'column', gap: 16, height: '100%' }}>
|
||||||
|
<Card
|
||||||
|
title={<Text strong>核销进度</Text>}
|
||||||
|
size="small"
|
||||||
|
style={{ flex: 4, display: 'flex', flexDirection: 'column', overflow: 'hidden' }}
|
||||||
|
styles={{ body: { padding: 12, flex: 1, display: 'flex', flexDirection: 'column', minHeight: 0 } }}
|
||||||
|
>
|
||||||
|
<div style={{ marginBottom: 16 }}>
|
||||||
|
<Progress percent={calculateTotalProgress()} status={status === 'completed' ? 'success' : 'active'} strokeWidth={10} />
|
||||||
|
</div>
|
||||||
|
<div style={{ flex: 1, overflowY: 'auto' }}>
|
||||||
|
{progressData.length ? progressData.map((item) => (
|
||||||
|
<div key={item.inventoryCode} style={{ padding: '8px 0', borderBottom: `1px solid ${token.colorBorderSecondary}` }}>
|
||||||
|
<Flex justify="space-between" align="center" style={{ marginBottom: 4 }}>
|
||||||
|
<Text strong style={{ fontSize: 13 }}>{item.inventoryName}</Text>
|
||||||
|
<Space>
|
||||||
|
<Text type="secondary" style={{ fontSize: 12 }}>{item.inventoryCode}</Text>
|
||||||
|
<Badge count={`${item.currentInspected} / ${item.quantify}`} style={{ backgroundColor: item.currentInspected === item.quantify ? token.colorSuccess : token.colorPrimary }} />
|
||||||
|
</Space>
|
||||||
|
</Flex>
|
||||||
|
<Progress percent={item.quantify > 0 ? Math.round((item.currentInspected / item.quantify) * 100) : 0} showInfo={false} size="small" />
|
||||||
|
</div>
|
||||||
|
)) : (
|
||||||
|
<Empty image={Empty.PRESENTED_IMAGE_SIMPLE} description="暂无核销数据" />
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
<Card
|
||||||
|
title={<Text strong>查验异常</Text>}
|
||||||
|
size="small"
|
||||||
|
style={{ flex: 3, display: 'flex', flexDirection: 'column', overflow: 'hidden' }}
|
||||||
|
styles={{ body: { padding: 0, flex: 1, display: 'flex', flexDirection: 'column', minHeight: 0 } }}
|
||||||
|
>
|
||||||
|
<div style={{ flex: 1, overflowY: 'auto' }}>
|
||||||
|
<Table columns={issueColumns} dataSource={issues} rowKey="id" size="small" pagination={false} sticky locale={{ emptyText: <Empty description="暂无异常" /> }} />
|
||||||
|
</div>
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
<Card
|
||||||
|
title={<Text strong>查验日志</Text>}
|
||||||
|
size="small"
|
||||||
|
style={{ flex: 3, display: 'flex', flexDirection: 'column', overflow: 'hidden' }}
|
||||||
|
styles={{ body: { padding: 12, flex: 1, display: 'flex', flexDirection: 'column', minHeight: 0, background: token.colorFillQuaternary } }}
|
||||||
|
>
|
||||||
|
<div ref={logsContainerRef} onScroll={handleLogsScroll} style={{ flex: 1, overflowY: 'auto', paddingRight: 8 }}>
|
||||||
|
{logs.length > 0 ? (
|
||||||
|
<Timeline
|
||||||
|
items={logs.map((item) => ({
|
||||||
|
color: item.type === 'success' ? 'green' : item.type === 'warning' ? 'orange' : 'blue',
|
||||||
|
children: (
|
||||||
|
<Space direction="vertical" size={0}>
|
||||||
|
<Text type="secondary" style={{ fontSize: 12 }}>{item.time}</Text>
|
||||||
|
<Text style={{ fontSize: 13 }}>{item.message}</Text>
|
||||||
|
</Space>
|
||||||
|
),
|
||||||
|
}))}
|
||||||
|
/>
|
||||||
|
) : (
|
||||||
|
<Empty image={Empty.PRESENTED_IMAGE_SIMPLE} description="暂无日志" style={{ margin: '20px 0' }} />
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</Card>
|
||||||
|
</Col>
|
||||||
|
</Row>
|
||||||
|
|
||||||
|
<Modal
|
||||||
|
title="暂停查验"
|
||||||
|
open={isPauseModalVisible}
|
||||||
|
onOk={confirmPause}
|
||||||
|
onCancel={() => setIsPauseModalVisible(false)}
|
||||||
|
okText="确认暂停"
|
||||||
|
cancelText="取消"
|
||||||
|
okButtonProps={{ danger: true, loading: operationLoading }}
|
||||||
|
>
|
||||||
|
<Flex vertical gap={16} style={{ paddingTop: 16 }}>
|
||||||
|
<Text>请确认是否暂停当前自动查验任务?</Text>
|
||||||
|
<TextArea
|
||||||
|
rows={4}
|
||||||
|
placeholder="请输入暂停原因"
|
||||||
|
value={pauseReason}
|
||||||
|
onChange={(event) => setPauseReason(event.target.value)}
|
||||||
|
/>
|
||||||
|
</Flex>
|
||||||
|
</Modal>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,49 @@
|
|||||||
|
import type { Metadata } from 'next';
|
||||||
|
import { AntdRegistry } from '@ant-design/nextjs-registry';
|
||||||
|
import { App, ConfigProvider } from 'antd';
|
||||||
|
import './globals.css';
|
||||||
|
import { TopHeader } from '@/components/TopHeader';
|
||||||
|
|
||||||
|
export const metadata: Metadata = {
|
||||||
|
title: '海关智慧查验平台',
|
||||||
|
description: '海关查验系统平板端',
|
||||||
|
};
|
||||||
|
|
||||||
|
export default function RootLayout({
|
||||||
|
children,
|
||||||
|
}: Readonly<{
|
||||||
|
children: React.ReactNode;
|
||||||
|
}>) {
|
||||||
|
return (
|
||||||
|
<html lang="zh-CN">
|
||||||
|
<body className="appBody">
|
||||||
|
<AntdRegistry>
|
||||||
|
<ConfigProvider
|
||||||
|
theme={{
|
||||||
|
token: {
|
||||||
|
colorPrimary: '#1677ff',
|
||||||
|
borderRadius: 8,
|
||||||
|
},
|
||||||
|
components: {
|
||||||
|
Card: {
|
||||||
|
borderRadiusLG: 12,
|
||||||
|
},
|
||||||
|
Statistic: {
|
||||||
|
contentFontSize: 32,
|
||||||
|
titleFontSize: 16,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<App className="antAppRoot">
|
||||||
|
<div className="appShell">
|
||||||
|
<TopHeader />
|
||||||
|
<main className="appMain">{children}</main>
|
||||||
|
</div>
|
||||||
|
</App>
|
||||||
|
</ConfigProvider>
|
||||||
|
</AntdRegistry>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,161 @@
|
|||||||
|
'use client';
|
||||||
|
|
||||||
|
import React, { useEffect, useState } from 'react';
|
||||||
|
import { Alert, Button, Card, Col, Empty, Flex, Image as AntImage, Row, Space, Spin, Table, Tabs, Typography } from 'antd';
|
||||||
|
import type { ColumnsType } from 'antd/es/table';
|
||||||
|
import { ArrowLeftOutlined } from '@ant-design/icons';
|
||||||
|
import { useRouter } from 'next/navigation';
|
||||||
|
import { Breadcrumb } from '@/components/Breadcrumb';
|
||||||
|
import { StatusBadge } from '@/components/StatusBadge';
|
||||||
|
import { BackendApi } from '@/services/backendApi';
|
||||||
|
import type { InspectionRecord, MachineDetail, MachineImageItem } from '@/types';
|
||||||
|
|
||||||
|
const { Text } = Typography;
|
||||||
|
|
||||||
|
export default function MachineDetailPage({ params }: { params: { serialNumber: string } }) {
|
||||||
|
const router = useRouter();
|
||||||
|
const serialNumber = decodeURIComponent(params.serialNumber);
|
||||||
|
const [machine, setMachine] = useState<MachineDetail | null>(null);
|
||||||
|
const [loading, setLoading] = useState(true);
|
||||||
|
const [errorMessage, setErrorMessage] = useState('');
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
let isMounted = true;
|
||||||
|
|
||||||
|
const loadMachineDetail = async () => {
|
||||||
|
try {
|
||||||
|
setLoading(true);
|
||||||
|
setErrorMessage('');
|
||||||
|
const data = await BackendApi.getMachineDetail(serialNumber);
|
||||||
|
if (!isMounted) return;
|
||||||
|
setMachine(data);
|
||||||
|
} catch (error) {
|
||||||
|
if (!isMounted) return;
|
||||||
|
setMachine(null);
|
||||||
|
setErrorMessage(error instanceof Error ? error.message : '机器详情加载失败,请稍后重试');
|
||||||
|
} finally {
|
||||||
|
if (isMounted) {
|
||||||
|
setLoading(false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
loadMachineDetail();
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
isMounted = false;
|
||||||
|
};
|
||||||
|
}, [serialNumber]);
|
||||||
|
|
||||||
|
const renderImageGroup = (images: MachineImageItem[]) => {
|
||||||
|
if (!images.length) return <Empty description="暂无图片" />;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<AntImage.PreviewGroup>
|
||||||
|
<Space size={[16, 16]} wrap>
|
||||||
|
{images.map((image) => (
|
||||||
|
<Flex key={image.id} vertical style={{ position: 'relative', width: 120, gap: 4 }}>
|
||||||
|
<div style={{ width: '100%', aspectRatio: '4/3', overflow: 'hidden', borderRadius: 8, background: '#f0f0f0' }}>
|
||||||
|
<AntImage
|
||||||
|
src={image.url}
|
||||||
|
alt={image.name}
|
||||||
|
width="100%"
|
||||||
|
height="100%"
|
||||||
|
style={{ objectFit: 'cover' }}
|
||||||
|
preview={{ src: image.url }}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<Text style={{ fontSize: 12, textAlign: 'center' }}>{image.name}</Text>
|
||||||
|
<Text type="secondary" style={{ fontSize: 11, textAlign: 'center' }}>{image.createdAt}</Text>
|
||||||
|
</Flex>
|
||||||
|
))}
|
||||||
|
</Space>
|
||||||
|
</AntImage.PreviewGroup>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
const recordColumns: ColumnsType<InspectionRecord> = [
|
||||||
|
{ title: '查验时间', dataIndex: 'time', key: 'time' },
|
||||||
|
{ title: '操作人', dataIndex: 'operator', key: 'operator' },
|
||||||
|
{ title: '结果', dataIndex: 'result', key: 'result' },
|
||||||
|
{ title: '备注', dataIndex: 'remark', key: 'remark' },
|
||||||
|
];
|
||||||
|
|
||||||
|
if (loading) {
|
||||||
|
return (
|
||||||
|
<Flex vertical align="center" justify="center" style={{ padding: 48 }}>
|
||||||
|
<Spin tip="正在加载机器详情..." />
|
||||||
|
</Flex>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!machine) {
|
||||||
|
return (
|
||||||
|
<Flex vertical align="center" style={{ padding: 48 }}>
|
||||||
|
{errorMessage && (
|
||||||
|
<Alert type="error" message={errorMessage} showIcon style={{ maxWidth: 480, marginBottom: 16 }} />
|
||||||
|
)}
|
||||||
|
<Empty description="暂无机器详情" />
|
||||||
|
<Button type="primary" onClick={() => router.push('/machines')} style={{ marginTop: 16 }}>
|
||||||
|
返回机器查询
|
||||||
|
</Button>
|
||||||
|
</Flex>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const imageTabs = [
|
||||||
|
{ key: 'incoming', label: '来料检验单', children: renderImageGroup(machine.images.incomingInspection) },
|
||||||
|
{ key: 'startup', label: '开机测试样张', children: renderImageGroup(machine.images.startupTestSample) },
|
||||||
|
{ key: 'production', label: '生产加工单', children: renderImageGroup(machine.images.productionOrder) },
|
||||||
|
{ key: 'robot', label: '机器人查验拍照', children: renderImageGroup(machine.images.robotInspection) },
|
||||||
|
];
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div>
|
||||||
|
<Flex justify="space-between" align="center" style={{ marginBottom: 16 }}>
|
||||||
|
<Breadcrumb />
|
||||||
|
<Button icon={<ArrowLeftOutlined />} onClick={() => router.back()}>返回查询</Button>
|
||||||
|
</Flex>
|
||||||
|
|
||||||
|
{errorMessage && (
|
||||||
|
<Alert type="warning" message={errorMessage} showIcon style={{ marginBottom: 16 }} />
|
||||||
|
)}
|
||||||
|
|
||||||
|
<Card title="机器基本信息" style={{ marginBottom: 24 }}>
|
||||||
|
<Row gutter={[24, 16]}>
|
||||||
|
<Col span={8}>
|
||||||
|
<Text type="secondary">序列号:</Text> <Text strong>{machine.serialNumber}</Text>
|
||||||
|
</Col>
|
||||||
|
<Col span={8}>
|
||||||
|
<Text type="secondary">机器型号:</Text> <Text strong>{machine.modelName}</Text>
|
||||||
|
</Col>
|
||||||
|
<Col span={8}>
|
||||||
|
<Text type="secondary">所属报关单:</Text> <Button type="link" disabled={machine.customsId === '-'}>{machine.customsId}</Button>
|
||||||
|
</Col>
|
||||||
|
<Col span={8}>
|
||||||
|
<Text type="secondary">当前状态:</Text> <StatusBadge status={machine.status} />
|
||||||
|
</Col>
|
||||||
|
{Object.entries(machine.specs).map(([key, value]) => (
|
||||||
|
<Col span={8} key={key}>
|
||||||
|
<Text type="secondary">{key}:</Text> <Text>{value}</Text>
|
||||||
|
</Col>
|
||||||
|
))}
|
||||||
|
</Row>
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
<Card title="图片资料" style={{ marginBottom: 24 }}>
|
||||||
|
<Tabs items={imageTabs} />
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
<Card title="查验记录">
|
||||||
|
<Table
|
||||||
|
dataSource={machine.inspectionRecords}
|
||||||
|
rowKey="id"
|
||||||
|
pagination={false}
|
||||||
|
columns={recordColumns}
|
||||||
|
locale={{ emptyText: <Empty description="暂无查验记录" /> }}
|
||||||
|
/>
|
||||||
|
</Card>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,187 @@
|
|||||||
|
'use client';
|
||||||
|
|
||||||
|
import React, { useEffect, useState } from 'react';
|
||||||
|
import { Button, Card, Col, Flex, Input, Modal, Row, Space, Table, Typography, Upload, message } from 'antd';
|
||||||
|
import type { ColumnsType } from 'antd/es/table';
|
||||||
|
import { BarcodeOutlined, BulbOutlined, CameraOutlined, FileImageOutlined, SearchOutlined } from '@ant-design/icons';
|
||||||
|
import { useRouter } from 'next/navigation';
|
||||||
|
import { Breadcrumb } from '@/components/Breadcrumb';
|
||||||
|
import type { RecentMachineQuery } from '@/types';
|
||||||
|
|
||||||
|
const { Title, Text } = Typography;
|
||||||
|
const { Dragger } = Upload;
|
||||||
|
|
||||||
|
const RECENT_QUERY_STORAGE_KEY = 'recent_queries';
|
||||||
|
|
||||||
|
export default function MachineQueryPage() {
|
||||||
|
const router = useRouter();
|
||||||
|
const [serialNumber, setSerialNumber] = useState('');
|
||||||
|
const [isScanModalVisible, setIsScanModalVisible] = useState(false);
|
||||||
|
const [recentQueries, setRecentQueries] = useState<RecentMachineQuery[]>([]);
|
||||||
|
const [messageApi, contextHolder] = message.useMessage();
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const saved = window.localStorage.getItem(RECENT_QUERY_STORAGE_KEY);
|
||||||
|
if (!saved) {
|
||||||
|
setRecentQueries([]);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
setRecentQueries(JSON.parse(saved) as RecentMachineQuery[]);
|
||||||
|
} catch {
|
||||||
|
setRecentQueries([]);
|
||||||
|
}
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const saveRecentQuery = (nextSerialNumber: string) => {
|
||||||
|
const query: RecentMachineQuery = {
|
||||||
|
serialNumber: nextSerialNumber,
|
||||||
|
name: '待后端返回',
|
||||||
|
time: new Date().toLocaleString(),
|
||||||
|
};
|
||||||
|
const updated = [query, ...recentQueries.filter((item) => item.serialNumber !== nextSerialNumber)].slice(0, 10);
|
||||||
|
setRecentQueries(updated);
|
||||||
|
window.localStorage.setItem(RECENT_QUERY_STORAGE_KEY, JSON.stringify(updated));
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleSearch = (value: string) => {
|
||||||
|
const nextSerialNumber = value.trim();
|
||||||
|
if (!nextSerialNumber) {
|
||||||
|
messageApi.warning('请输入序列号');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
saveRecentQuery(nextSerialNumber);
|
||||||
|
router.push(`/machines/${encodeURIComponent(nextSerialNumber)}`);
|
||||||
|
};
|
||||||
|
|
||||||
|
const columns: ColumnsType<RecentMachineQuery> = [
|
||||||
|
{ title: '序列号', dataIndex: 'serialNumber', key: 'serialNumber' },
|
||||||
|
{ title: '机器名称', dataIndex: 'name', key: 'name' },
|
||||||
|
{ title: '查询时间', dataIndex: 'time', key: 'time' },
|
||||||
|
{
|
||||||
|
title: '操作',
|
||||||
|
key: 'action',
|
||||||
|
render: (_, record) => (
|
||||||
|
<Button type="link" onClick={() => handleSearch(record.serialNumber)}>再次查看</Button>
|
||||||
|
),
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div>
|
||||||
|
{contextHolder}
|
||||||
|
<Breadcrumb />
|
||||||
|
|
||||||
|
<Card title="查询方式选择" style={{ marginBottom: 24 }}>
|
||||||
|
<Row gutter={48}>
|
||||||
|
<Col span={12} style={{ borderRight: '1px solid var(--color-border-light)' }}>
|
||||||
|
<Flex vertical align="center" gap={16} style={{ padding: '24px 0' }}>
|
||||||
|
<CameraOutlined style={{ fontSize: 48, color: 'var(--ant-color-primary, #1677ff)' }} />
|
||||||
|
<Title level={4} style={{ margin: 0 }}>扫描二维码</Title>
|
||||||
|
<Text type="secondary">使用平板摄像头扫描机器机身二维码</Text>
|
||||||
|
<Button type="primary" size="large" onClick={() => setIsScanModalVisible(true)}>打开扫码</Button>
|
||||||
|
</Flex>
|
||||||
|
</Col>
|
||||||
|
<Col span={12}>
|
||||||
|
<Flex vertical align="center" gap={16} style={{ padding: '24px 0' }}>
|
||||||
|
<BarcodeOutlined style={{ fontSize: 48, color: 'var(--ant-color-primary, #1677ff)' }} />
|
||||||
|
<Title level={4} style={{ margin: 0 }}>输入序列号</Title>
|
||||||
|
<Text type="secondary">输入机器序列号精确查询机器信息</Text>
|
||||||
|
<Space.Compact style={{ width: '80%' }}>
|
||||||
|
<Input
|
||||||
|
placeholder="请输入序列号..."
|
||||||
|
size="large"
|
||||||
|
value={serialNumber}
|
||||||
|
onChange={(event) => setSerialNumber(event.target.value)}
|
||||||
|
onPressEnter={() => handleSearch(serialNumber)}
|
||||||
|
/>
|
||||||
|
<Button type="primary" size="large" icon={<SearchOutlined />} onClick={() => handleSearch(serialNumber)}>查询</Button>
|
||||||
|
</Space.Compact>
|
||||||
|
</Flex>
|
||||||
|
</Col>
|
||||||
|
</Row>
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
<Card title="或上传二维码照片识别" style={{ marginBottom: 24 }}>
|
||||||
|
<Dragger
|
||||||
|
accept="image/*"
|
||||||
|
showUploadList={false}
|
||||||
|
beforeUpload={() => {
|
||||||
|
messageApi.info('图片二维码识别后端暂未提供,请手动输入序列号');
|
||||||
|
return Upload.LIST_IGNORE;
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<p className="ant-upload-drag-icon">
|
||||||
|
<FileImageOutlined style={{ fontSize: 48 }} />
|
||||||
|
</p>
|
||||||
|
<p className="ant-upload-text">拖拽或点击上传二维码照片</p>
|
||||||
|
<p className="ant-upload-hint">当前版本保留入口,待后端提供图片识别接口后接入</p>
|
||||||
|
</Dragger>
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
<Card title="最近查询记录">
|
||||||
|
<Table
|
||||||
|
dataSource={recentQueries}
|
||||||
|
rowKey="serialNumber"
|
||||||
|
pagination={false}
|
||||||
|
size="middle"
|
||||||
|
columns={columns}
|
||||||
|
/>
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
<Modal
|
||||||
|
title="扫描二维码"
|
||||||
|
open={isScanModalVisible}
|
||||||
|
onCancel={() => setIsScanModalVisible(false)}
|
||||||
|
footer={[
|
||||||
|
<Button key="manual" onClick={() => setIsScanModalVisible(false)}>
|
||||||
|
手动输入序列号
|
||||||
|
</Button>,
|
||||||
|
<Button key="close" type="primary" onClick={() => setIsScanModalVisible(false)}>
|
||||||
|
关闭
|
||||||
|
</Button>,
|
||||||
|
]}
|
||||||
|
width={600}
|
||||||
|
centered
|
||||||
|
>
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
position: 'relative',
|
||||||
|
display: 'flex',
|
||||||
|
height: 300,
|
||||||
|
alignItems: 'center',
|
||||||
|
justifyContent: 'center',
|
||||||
|
borderRadius: 8,
|
||||||
|
background: '#000000',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Flex vertical align="center" gap={16} style={{ color: '#ffffff', zIndex: 1 }}>
|
||||||
|
<CameraOutlined style={{ fontSize: 48, opacity: 0.5 }} />
|
||||||
|
<div>平板摄像头扫码入口占位</div>
|
||||||
|
<Text style={{ color: 'rgba(255,255,255,0.65)' }}>浏览器扫码与后端识别接口尚未接入</Text>
|
||||||
|
</Flex>
|
||||||
|
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
position: 'absolute',
|
||||||
|
width: 200,
|
||||||
|
height: 200,
|
||||||
|
border: '2px solid rgba(24, 144, 255, 0.5)',
|
||||||
|
boxShadow: '0 0 0 9999px rgba(0, 0, 0, 0.5)',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<div className="scanCorner" style={{ top: -2, left: -2, borderTop: '4px solid #1890ff', borderLeft: '4px solid #1890ff' }} />
|
||||||
|
<div className="scanCorner" style={{ top: -2, right: -2, borderTop: '4px solid #1890ff', borderRight: '4px solid #1890ff' }} />
|
||||||
|
<div className="scanCorner" style={{ bottom: -2, left: -2, borderBottom: '4px solid #1890ff', borderLeft: '4px solid #1890ff' }} />
|
||||||
|
<div className="scanCorner" style={{ bottom: -2, right: -2, borderBottom: '4px solid #1890ff', borderRight: '4px solid #1890ff' }} />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div style={{ marginTop: 16, textAlign: 'center', color: '#666666' }}>
|
||||||
|
<BulbOutlined /> 可先使用序列号查询真实后端资料
|
||||||
|
</div>
|
||||||
|
</Modal>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,262 @@
|
|||||||
|
'use client';
|
||||||
|
|
||||||
|
import React, { useEffect, useState } from 'react';
|
||||||
|
import { Alert, Button, Card, Col, Empty, Flex, Row, Statistic, Table, message } from 'antd';
|
||||||
|
import type { ColumnsType } from 'antd/es/table';
|
||||||
|
import {
|
||||||
|
CheckCircleOutlined,
|
||||||
|
ClockCircleOutlined,
|
||||||
|
FileTextOutlined,
|
||||||
|
ProfileOutlined,
|
||||||
|
RightOutlined,
|
||||||
|
ScanOutlined,
|
||||||
|
SearchOutlined,
|
||||||
|
SyncOutlined,
|
||||||
|
VideoCameraOutlined,
|
||||||
|
WarningOutlined,
|
||||||
|
} from '@ant-design/icons';
|
||||||
|
import { useRouter } from 'next/navigation';
|
||||||
|
import { BackendApi } from '@/services/backendApi';
|
||||||
|
import type { ActivityItem, CustomsDeclaration, CustomsStats } from '@/types';
|
||||||
|
import { StatusBadge } from '@/components/StatusBadge';
|
||||||
|
|
||||||
|
export default function DashboardPage() {
|
||||||
|
const router = useRouter();
|
||||||
|
const [stats, setStats] = useState<CustomsStats | null>(null);
|
||||||
|
const [activities, setActivities] = useState<ActivityItem[]>([]);
|
||||||
|
const [pendingCustoms, setPendingCustoms] = useState<CustomsDeclaration[]>([]);
|
||||||
|
const [errorMessage, setErrorMessage] = useState('');
|
||||||
|
const [messageApi, contextHolder] = message.useMessage();
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
let isMounted = true;
|
||||||
|
|
||||||
|
const loadData = async () => {
|
||||||
|
try {
|
||||||
|
setErrorMessage('');
|
||||||
|
const [statsData, activityData, customsData] = await Promise.all([
|
||||||
|
BackendApi.getCustomsStats(),
|
||||||
|
BackendApi.getRecentActivities(),
|
||||||
|
BackendApi.getCustomsList(1, 10),
|
||||||
|
]);
|
||||||
|
|
||||||
|
if (!isMounted) return;
|
||||||
|
|
||||||
|
setStats(statsData);
|
||||||
|
setActivities(activityData);
|
||||||
|
setPendingCustoms(customsData.filter((item) => item.status === 'pending' || item.status === 'inspecting'));
|
||||||
|
} catch (error) {
|
||||||
|
if (!isMounted) return;
|
||||||
|
setErrorMessage(error instanceof Error ? error.message : '首页数据加载失败,请稍后重试');
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
loadData();
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
isMounted = false;
|
||||||
|
};
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const goToInspection = async () => {
|
||||||
|
try {
|
||||||
|
const inspection = await BackendApi.getCurrentInspection();
|
||||||
|
if (inspection) {
|
||||||
|
router.push(`/inspection?customsId=${encodeURIComponent(inspection.customsId)}`);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
router.push('/customs');
|
||||||
|
} catch {
|
||||||
|
router.push('/customs');
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const statCards = [
|
||||||
|
{
|
||||||
|
title: '待查验',
|
||||||
|
value: stats?.pendingCount || 0,
|
||||||
|
icon: <FileTextOutlined />,
|
||||||
|
suffix: '份报关单',
|
||||||
|
contentStyle: { color: '#1890ff' },
|
||||||
|
onClick: () => router.push('/customs'),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: '今日已放行',
|
||||||
|
value: stats?.releasedToday || 0,
|
||||||
|
icon: <CheckCircleOutlined />,
|
||||||
|
suffix: '份报关单',
|
||||||
|
contentStyle: { color: '#52c41a' },
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: '查验进行中',
|
||||||
|
value: stats?.inspectingCount || 0,
|
||||||
|
icon: <SyncOutlined spin />,
|
||||||
|
suffix: '个任务',
|
||||||
|
contentStyle: { color: '#faad14' },
|
||||||
|
onClick: goToInspection,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: '异常',
|
||||||
|
value: stats?.abnormalCount || 0,
|
||||||
|
icon: <WarningOutlined />,
|
||||||
|
suffix: '个异常',
|
||||||
|
contentStyle: { color: '#ff4d4f' },
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
const quickActions = [
|
||||||
|
{ title: '扫码查询机器', desc: '使用平板摄像头扫描设备二维码', icon: <ScanOutlined />, onClick: () => router.push('/machines'), color: '#1890ff' },
|
||||||
|
{ title: '序列号查询机器', desc: '手动输入序列号查询机器全部资料', icon: <SearchOutlined />, onClick: () => router.push('/machines'), color: '#1890ff' },
|
||||||
|
{ title: '视频监控', desc: '查看已接入与待接入的视频画面', icon: <VideoCameraOutlined />, onClick: () => router.push('/video'), color: '#1890ff' },
|
||||||
|
];
|
||||||
|
|
||||||
|
const pendingColumns: ColumnsType<CustomsDeclaration> = [
|
||||||
|
{
|
||||||
|
title: '报关单号',
|
||||||
|
dataIndex: 'customsName',
|
||||||
|
key: 'customsName',
|
||||||
|
render: (text: string) => <b>{text}</b>,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: '机器数量',
|
||||||
|
dataIndex: 'machineCount',
|
||||||
|
key: 'machineCount',
|
||||||
|
render: (count: number) => (count ? `${count} 台` : '-'),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: '状态',
|
||||||
|
dataIndex: 'status',
|
||||||
|
key: 'status',
|
||||||
|
render: (status: CustomsDeclaration['status']) => <StatusBadge status={status} />,
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div style={{ paddingBottom: 24 }}>
|
||||||
|
{contextHolder}
|
||||||
|
{errorMessage && (
|
||||||
|
<Alert
|
||||||
|
type="error"
|
||||||
|
message={errorMessage}
|
||||||
|
showIcon
|
||||||
|
style={{ marginBottom: 16 }}
|
||||||
|
action={<Button size="small" onClick={() => router.refresh()}>刷新</Button>}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<Row gutter={24} style={{ marginBottom: 24 }}>
|
||||||
|
{statCards.map((stat) => (
|
||||||
|
<Col span={6} key={stat.title}>
|
||||||
|
<Card hoverable={Boolean(stat.onClick)} onClick={stat.onClick}>
|
||||||
|
<Statistic
|
||||||
|
title={stat.title}
|
||||||
|
value={stat.value}
|
||||||
|
suffix={stat.suffix}
|
||||||
|
prefix={stat.icon}
|
||||||
|
styles={{ content: stat.contentStyle }}
|
||||||
|
loading={!stats && !errorMessage}
|
||||||
|
/>
|
||||||
|
</Card>
|
||||||
|
</Col>
|
||||||
|
))}
|
||||||
|
</Row>
|
||||||
|
|
||||||
|
<div style={{ marginBottom: 32, marginTop: 16 }}>
|
||||||
|
<div style={{ fontSize: 18, marginBottom: 16, fontWeight: 600, color: '#333' }}>快捷功能板块</div>
|
||||||
|
<Row gutter={24}>
|
||||||
|
{quickActions.map((action) => (
|
||||||
|
<Col span={8} key={action.title}>
|
||||||
|
<Card
|
||||||
|
hoverable
|
||||||
|
onClick={action.onClick}
|
||||||
|
style={{
|
||||||
|
borderRadius: 8,
|
||||||
|
overflow: 'hidden',
|
||||||
|
border: '1px solid #f0f0f0',
|
||||||
|
boxShadow: '0 2px 8px rgba(0,0,0,0.06)',
|
||||||
|
}}
|
||||||
|
styles={{ body: { padding: 24, background: '#fff', height: '100%' } }}
|
||||||
|
>
|
||||||
|
<Flex align="center" gap={20}>
|
||||||
|
<Flex
|
||||||
|
align="center"
|
||||||
|
justify="center"
|
||||||
|
style={{
|
||||||
|
width: 56,
|
||||||
|
height: 56,
|
||||||
|
borderRadius: 8,
|
||||||
|
background: '#f0f5ff',
|
||||||
|
color: action.color,
|
||||||
|
fontSize: 28,
|
||||||
|
flexShrink: 0,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{action.icon}
|
||||||
|
</Flex>
|
||||||
|
<Flex vertical gap={4} style={{ flex: 1 }}>
|
||||||
|
<div style={{ fontWeight: 600, fontSize: 18, color: '#262626' }}>{action.title}</div>
|
||||||
|
<div style={{ color: '#595959', fontSize: 13, lineHeight: 1.5 }}>{action.desc}</div>
|
||||||
|
</Flex>
|
||||||
|
</Flex>
|
||||||
|
</Card>
|
||||||
|
</Col>
|
||||||
|
))}
|
||||||
|
</Row>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<Row gutter={24}>
|
||||||
|
<Col span={12}>
|
||||||
|
<Card
|
||||||
|
title="最近查验动态"
|
||||||
|
extra={<Button type="link" icon={<RightOutlined />} onClick={() => messageApi.info('后端暂未提供完整动态列表接口')}>查看全部</Button>}
|
||||||
|
styles={{ body: { height: 'calc(100% - 57px)' } }}
|
||||||
|
>
|
||||||
|
{activities.length ? (
|
||||||
|
<Flex vertical>
|
||||||
|
{activities.map((item) => (
|
||||||
|
<Flex
|
||||||
|
key={item.id}
|
||||||
|
align="center"
|
||||||
|
gap={12}
|
||||||
|
style={{ padding: '12px 0', borderBottom: '1px solid #f0f0f0' }}
|
||||||
|
>
|
||||||
|
{item.type === 'start' ? <ClockCircleOutlined style={{ fontSize: 20 }} /> :
|
||||||
|
item.type === 'success' ? <CheckCircleOutlined style={{ fontSize: 20 }} /> :
|
||||||
|
item.type === 'warning' ? <WarningOutlined style={{ fontSize: 20 }} /> :
|
||||||
|
<ProfileOutlined style={{ fontSize: 20 }} />}
|
||||||
|
<Flex vertical gap={2}>
|
||||||
|
<span style={{ fontWeight: 500 }}>{item.message}</span>
|
||||||
|
<span style={{ color: '#8c8c8c', fontSize: 13 }}>{item.time}</span>
|
||||||
|
</Flex>
|
||||||
|
</Flex>
|
||||||
|
))}
|
||||||
|
</Flex>
|
||||||
|
) : (
|
||||||
|
<Empty image={Empty.PRESENTED_IMAGE_SIMPLE} description="暂无后端动态" />
|
||||||
|
)}
|
||||||
|
</Card>
|
||||||
|
</Col>
|
||||||
|
|
||||||
|
<Col span={12}>
|
||||||
|
<Card
|
||||||
|
title="待查验报关单"
|
||||||
|
extra={<Button type="link" onClick={() => router.push('/customs')}>查看全部 <RightOutlined /></Button>}
|
||||||
|
styles={{ body: { height: 'calc(100% - 57px)' } }}
|
||||||
|
>
|
||||||
|
<Table
|
||||||
|
dataSource={pendingCustoms}
|
||||||
|
rowKey="id"
|
||||||
|
columns={pendingColumns}
|
||||||
|
pagination={false}
|
||||||
|
size="small"
|
||||||
|
onRow={() => ({
|
||||||
|
onClick: () => router.push('/customs'),
|
||||||
|
style: { cursor: 'pointer' },
|
||||||
|
})}
|
||||||
|
/>
|
||||||
|
</Card>
|
||||||
|
</Col>
|
||||||
|
</Row>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,113 @@
|
|||||||
|
'use client';
|
||||||
|
|
||||||
|
import React, { useEffect, useState } from 'react';
|
||||||
|
import Link from 'next/link';
|
||||||
|
import { Alert, Badge, Breadcrumb as AntdBreadcrumb, Button, Card, Col, Empty, Flex, Row, Space, Spin, Typography, message } from 'antd';
|
||||||
|
import { ArrowLeftOutlined, CameraOutlined, FullscreenOutlined, HomeOutlined, ReloadOutlined } from '@ant-design/icons';
|
||||||
|
import { Breadcrumb } from '@/components/Breadcrumb';
|
||||||
|
import { CameraFrame } from '@/components/CameraFrame';
|
||||||
|
import { BackendApi } from '@/services/backendApi';
|
||||||
|
import type { CameraInfo } from '@/types';
|
||||||
|
|
||||||
|
const { Text } = Typography;
|
||||||
|
|
||||||
|
export default function VideoPage() {
|
||||||
|
const [cameras, setCameras] = useState<CameraInfo[]>([]);
|
||||||
|
const [fullscreenCamera, setFullscreenCamera] = useState<CameraInfo | null>(null);
|
||||||
|
const [loading, setLoading] = useState(true);
|
||||||
|
const [errorMessage, setErrorMessage] = useState('');
|
||||||
|
const [messageApi, contextHolder] = message.useMessage();
|
||||||
|
|
||||||
|
const loadCameras = async () => {
|
||||||
|
try {
|
||||||
|
setLoading(true);
|
||||||
|
setErrorMessage('');
|
||||||
|
const cameraList = await BackendApi.getCameras();
|
||||||
|
setCameras(cameraList);
|
||||||
|
} catch (error) {
|
||||||
|
setErrorMessage(error instanceof Error ? error.message : '摄像头列表加载失败,请稍后重试');
|
||||||
|
} finally {
|
||||||
|
setLoading(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
loadCameras();
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
if (fullscreenCamera) {
|
||||||
|
return (
|
||||||
|
<div>
|
||||||
|
{contextHolder}
|
||||||
|
<Flex justify="space-between" align="center" style={{ marginBottom: 16 }}>
|
||||||
|
<Space align="center" size="middle">
|
||||||
|
<Button icon={<ArrowLeftOutlined />} onClick={() => setFullscreenCamera(null)}>返回</Button>
|
||||||
|
<AntdBreadcrumb
|
||||||
|
items={[
|
||||||
|
{ title: <Link href="/"><HomeOutlined /> 首页</Link> },
|
||||||
|
{ title: <a href="#" onClick={(event) => { event.preventDefault(); setFullscreenCamera(null); }}>视频监控</a> },
|
||||||
|
{ title: fullscreenCamera.name },
|
||||||
|
]}
|
||||||
|
/>
|
||||||
|
</Space>
|
||||||
|
<Button type="primary" icon={<CameraOutlined />} onClick={() => messageApi.info('截图接口暂未提供')}>截图</Button>
|
||||||
|
</Flex>
|
||||||
|
|
||||||
|
<Flex justify="center" align="center" style={{ height: 'calc(100vh - 180px)', marginBottom: 16 }}>
|
||||||
|
<div style={{ height: '100%', maxWidth: '100%', aspectRatio: '16 / 9', boxShadow: '0 8px 24px rgba(0,0,0,0.1)' }}>
|
||||||
|
<CameraFrame camera={fullscreenCamera} height="100%" />
|
||||||
|
</div>
|
||||||
|
</Flex>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div>
|
||||||
|
{contextHolder}
|
||||||
|
<Flex justify="space-between" align="center" style={{ marginBottom: 24 }}>
|
||||||
|
<Breadcrumb />
|
||||||
|
<Space>
|
||||||
|
<Button icon={<ReloadOutlined />} onClick={loadCameras}>刷新</Button>
|
||||||
|
<Button icon={<FullscreenOutlined />} onClick={() => messageApi.info('请选择一个在线画面进入全屏')}>全屏模式</Button>
|
||||||
|
<Button type="primary" icon={<CameraOutlined />} onClick={() => messageApi.info('全部截图接口暂未提供')}>全部截图</Button>
|
||||||
|
</Space>
|
||||||
|
</Flex>
|
||||||
|
|
||||||
|
{errorMessage && (
|
||||||
|
<Alert type="error" message={errorMessage} showIcon style={{ marginBottom: 24 }} />
|
||||||
|
)}
|
||||||
|
|
||||||
|
{loading ? (
|
||||||
|
<Flex vertical align="center" justify="center" style={{ padding: 64 }}>
|
||||||
|
<Spin size="large" tip="正在加载摄像头..." />
|
||||||
|
</Flex>
|
||||||
|
) : cameras.length ? (
|
||||||
|
<Row gutter={[24, 24]}>
|
||||||
|
{cameras.map((camera) => (
|
||||||
|
<Col xs={24} lg={12} key={camera.id}>
|
||||||
|
<Card
|
||||||
|
hoverable={camera.status === 'online'}
|
||||||
|
style={{ overflow: 'hidden', borderRadius: 12, borderColor: camera.status === 'online' ? '#f0f0f0' : '#ffccc7' }}
|
||||||
|
styles={{ body: { padding: 0 } }}
|
||||||
|
onClick={() => camera.status === 'online' && setFullscreenCamera(camera)}
|
||||||
|
>
|
||||||
|
<CameraFrame camera={camera} height={300} />
|
||||||
|
<Flex justify="space-between" align="center" style={{ padding: '12px 20px', background: camera.status === 'online' ? '#ffffff' : '#fff1f0', borderTop: '1px solid #f0f0f0' }}>
|
||||||
|
<Space size="middle">
|
||||||
|
<Badge status={camera.status === 'online' ? 'processing' : 'error'} />
|
||||||
|
<Text strong style={{ fontSize: 15, color: camera.status === 'online' ? 'inherit' : '#cf1322' }}>{camera.name}</Text>
|
||||||
|
{camera.placeholder && <Text type="secondary" style={{ fontSize: 12 }}>占位</Text>}
|
||||||
|
</Space>
|
||||||
|
{camera.status === 'online' && <Button type="link" icon={<FullscreenOutlined />} size="small">全屏观看</Button>}
|
||||||
|
</Flex>
|
||||||
|
</Card>
|
||||||
|
</Col>
|
||||||
|
))}
|
||||||
|
</Row>
|
||||||
|
) : (
|
||||||
|
<Empty description="暂无摄像头数据" />
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,50 @@
|
|||||||
|
'use client';
|
||||||
|
|
||||||
|
import React from 'react';
|
||||||
|
import Link from 'next/link';
|
||||||
|
import { usePathname } from 'next/navigation';
|
||||||
|
import { Breadcrumb as AntdBreadcrumb } from 'antd';
|
||||||
|
import { HomeOutlined } from '@ant-design/icons';
|
||||||
|
|
||||||
|
const breadcrumbNameMap: Record<string, string> = {
|
||||||
|
'/video': '视频监控',
|
||||||
|
'/machines': '机器查询',
|
||||||
|
'/customs': '报关单管理',
|
||||||
|
'/inspection': '远程查验',
|
||||||
|
};
|
||||||
|
|
||||||
|
export const Breadcrumb: React.FC = () => {
|
||||||
|
const pathname = usePathname();
|
||||||
|
const pathSnippets = pathname.split('/').filter(Boolean);
|
||||||
|
|
||||||
|
const extraBreadcrumbItems = pathSnippets.map((_, index) => {
|
||||||
|
const url = `/${pathSnippets.slice(0, index + 1).join('/')}`;
|
||||||
|
const isLast = index === pathSnippets.length - 1;
|
||||||
|
let name = breadcrumbNameMap[url] || pathSnippets[index];
|
||||||
|
|
||||||
|
if (!breadcrumbNameMap[url] && pathSnippets[index - 1] === 'machines') {
|
||||||
|
name = '机器详情';
|
||||||
|
} else if (!breadcrumbNameMap[url] && pathSnippets[index - 1] === 'customs') {
|
||||||
|
name = '报关单详情';
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
key: url,
|
||||||
|
title: isLast ? name : <Link href={url}>{name}</Link>,
|
||||||
|
};
|
||||||
|
});
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div style={{ marginBottom: 16 }}>
|
||||||
|
<AntdBreadcrumb
|
||||||
|
items={[
|
||||||
|
{
|
||||||
|
key: 'home',
|
||||||
|
title: <Link href="/"><HomeOutlined /> 首页</Link>,
|
||||||
|
},
|
||||||
|
...extraBreadcrumbItems,
|
||||||
|
]}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
@@ -0,0 +1,91 @@
|
|||||||
|
'use client';
|
||||||
|
|
||||||
|
import React, { useMemo, useState } from 'react';
|
||||||
|
import { Button, Empty, Flex, Typography } from 'antd';
|
||||||
|
import { CaretRightOutlined, ReloadOutlined, VideoCameraOutlined } from '@ant-design/icons';
|
||||||
|
import type { CameraInfo } from '@/types';
|
||||||
|
|
||||||
|
const { Text } = Typography;
|
||||||
|
|
||||||
|
interface CameraFrameProps {
|
||||||
|
camera?: CameraInfo;
|
||||||
|
active?: boolean;
|
||||||
|
height?: number | string;
|
||||||
|
aspectRatio?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const CameraFrame: React.FC<CameraFrameProps> = ({ camera, active = true, height, aspectRatio }) => {
|
||||||
|
const [reloadKey, setReloadKey] = useState(Date.now());
|
||||||
|
const streamUrl = useMemo(() => {
|
||||||
|
if (!camera?.streamUrl) {
|
||||||
|
return '';
|
||||||
|
}
|
||||||
|
return `${camera.streamUrl}${camera.streamUrl.includes('?') ? '&' : '?'}t=${reloadKey}`;
|
||||||
|
}, [camera?.streamUrl, reloadKey]);
|
||||||
|
|
||||||
|
const offline = !camera || camera.status !== 'online' || camera.placeholder;
|
||||||
|
const isPollingJpeg = camera?.streamUrl === '/api/camera/refresh';
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
position: 'relative',
|
||||||
|
display: 'flex',
|
||||||
|
flexDirection: 'column',
|
||||||
|
alignItems: 'center',
|
||||||
|
justifyContent: 'center',
|
||||||
|
overflow: 'hidden',
|
||||||
|
borderRadius: 8,
|
||||||
|
background: '#141414',
|
||||||
|
height: height || '100%',
|
||||||
|
width: '100%',
|
||||||
|
flex: 1,
|
||||||
|
aspectRatio,
|
||||||
|
boxShadow: 'inset 0 0 20px rgba(0,0,0,0.5)',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{!offline && active && streamUrl ? (
|
||||||
|
<>
|
||||||
|
{/* 后端的 AGV 接口是单帧 JPEG,机械臂接口是 MJPEG;img 可同时承载两者。 */}
|
||||||
|
{/* eslint-disable-next-line @next/next/no-img-element */}
|
||||||
|
<img
|
||||||
|
key={camera.id}
|
||||||
|
className="cameraFrameImage"
|
||||||
|
src={streamUrl}
|
||||||
|
alt={camera.name}
|
||||||
|
onLoad={() => {
|
||||||
|
if (isPollingJpeg && active) {
|
||||||
|
window.setTimeout(() => setReloadKey(Date.now()), 1500);
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
<div style={{ position: 'absolute', bottom: 16, left: 16, zIndex: 10, padding: '4px 10px', background: 'rgba(0,0,0,0.6)', borderRadius: 6 }}>
|
||||||
|
<Text style={{ color: '#ffffff', fontSize: 12 }}>{camera.name} / 实时画面</Text>
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
) : offline ? (
|
||||||
|
<Empty
|
||||||
|
image={<VideoCameraOutlined style={{ fontSize: 64, color: '#ff4d4f', opacity: 0.9 }} />}
|
||||||
|
imageStyle={{ height: 64, marginBottom: 16 }}
|
||||||
|
description={
|
||||||
|
<Flex vertical gap={2}>
|
||||||
|
<Text type="danger" strong style={{ fontSize: 16 }}>{camera?.placeholder ? '摄像头未配置' : '设备离线'}</Text>
|
||||||
|
<Text type="secondary" style={{ color: 'rgba(255,255,255,0.45)' }}>{camera?.location ?? '暂无视频源'}</Text>
|
||||||
|
</Flex>
|
||||||
|
}
|
||||||
|
>
|
||||||
|
{camera?.streamUrl && (
|
||||||
|
<Button type="primary" danger icon={<ReloadOutlined />} style={{ marginTop: 8 }} onClick={() => setReloadKey(Date.now())}>
|
||||||
|
重试连接
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
</Empty>
|
||||||
|
) : (
|
||||||
|
<Flex vertical align="center" gap={16} style={{ color: '#ffffff' }}>
|
||||||
|
<CaretRightOutlined style={{ fontSize: 48, opacity: 0.65 }} />
|
||||||
|
<Text style={{ color: '#ffffff' }}>画面已暂停或未启动</Text>
|
||||||
|
</Flex>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
@@ -0,0 +1,67 @@
|
|||||||
|
import React from 'react';
|
||||||
|
import { Tag } from 'antd';
|
||||||
|
import {
|
||||||
|
CheckCircleOutlined,
|
||||||
|
ClockCircleOutlined,
|
||||||
|
CloseCircleOutlined,
|
||||||
|
MinusCircleOutlined,
|
||||||
|
PauseCircleOutlined,
|
||||||
|
QuestionCircleOutlined,
|
||||||
|
SyncOutlined,
|
||||||
|
WarningOutlined,
|
||||||
|
} from '@ant-design/icons';
|
||||||
|
|
||||||
|
type StatusType =
|
||||||
|
| 'pending'
|
||||||
|
| 'inspecting'
|
||||||
|
| 'released'
|
||||||
|
| 'abnormal'
|
||||||
|
| 'idle'
|
||||||
|
| 'running'
|
||||||
|
| 'paused'
|
||||||
|
| 'completed'
|
||||||
|
| 'online'
|
||||||
|
| 'offline';
|
||||||
|
|
||||||
|
interface StatusBadgeProps {
|
||||||
|
status: StatusType;
|
||||||
|
type?: 'badge' | 'tag';
|
||||||
|
}
|
||||||
|
|
||||||
|
export const StatusBadge: React.FC<StatusBadgeProps> = ({ status, type = 'badge' }) => {
|
||||||
|
const config = (() => {
|
||||||
|
switch (status) {
|
||||||
|
case 'pending':
|
||||||
|
return { color: 'warning', text: '待查验', icon: <ClockCircleOutlined style={{ color: '#faad14' }} /> };
|
||||||
|
case 'inspecting':
|
||||||
|
case 'running':
|
||||||
|
return { color: 'processing', text: '查验中', icon: <SyncOutlined style={{ color: '#1890ff' }} /> };
|
||||||
|
case 'released':
|
||||||
|
case 'completed':
|
||||||
|
return { color: 'success', text: '已放行', icon: <CheckCircleOutlined style={{ color: '#52c41a' }} /> };
|
||||||
|
case 'abnormal':
|
||||||
|
return { color: 'error', text: '异常', icon: <WarningOutlined style={{ color: '#ff4d4f' }} /> };
|
||||||
|
case 'idle':
|
||||||
|
return { color: 'default', text: '空闲', icon: <MinusCircleOutlined style={{ color: '#d9d9d9' }} /> };
|
||||||
|
case 'paused':
|
||||||
|
return { color: 'warning', text: '已暂停', icon: <PauseCircleOutlined style={{ color: '#faad14' }} /> };
|
||||||
|
case 'online':
|
||||||
|
return { color: 'success', text: '在线', icon: <CheckCircleOutlined style={{ color: '#52c41a' }} /> };
|
||||||
|
case 'offline':
|
||||||
|
return { color: 'error', text: '离线', icon: <CloseCircleOutlined style={{ color: '#ff4d4f' }} /> };
|
||||||
|
default:
|
||||||
|
return { color: 'default', text: '未知', icon: <QuestionCircleOutlined style={{ color: '#d9d9d9' }} /> };
|
||||||
|
}
|
||||||
|
})();
|
||||||
|
|
||||||
|
if (type === 'tag') {
|
||||||
|
return <Tag color={config.color} icon={config.icon}>{config.text}</Tag>;
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<span style={{ display: 'inline-flex', alignItems: 'center', gap: 4 }}>
|
||||||
|
{config.icon}
|
||||||
|
<span>{config.text}</span>
|
||||||
|
</span>
|
||||||
|
);
|
||||||
|
};
|
||||||
@@ -0,0 +1,144 @@
|
|||||||
|
'use client';
|
||||||
|
|
||||||
|
import React, { useEffect, useState } from 'react';
|
||||||
|
import { Avatar, Badge, Button, Dropdown, Layout, Menu, Space, Switch, Typography, message, theme } from 'antd';
|
||||||
|
import {
|
||||||
|
BellOutlined,
|
||||||
|
DashboardOutlined,
|
||||||
|
FileTextOutlined,
|
||||||
|
SafetyCertificateOutlined,
|
||||||
|
ScanOutlined,
|
||||||
|
SearchOutlined,
|
||||||
|
SettingOutlined,
|
||||||
|
UserOutlined,
|
||||||
|
VideoCameraOutlined,
|
||||||
|
} from '@ant-design/icons';
|
||||||
|
import { usePathname, useRouter } from 'next/navigation';
|
||||||
|
import { BackendApi } from '@/services/backendApi';
|
||||||
|
import { useAppStore } from '@/store/useAppStore';
|
||||||
|
import type { ApiMode } from '@/types';
|
||||||
|
|
||||||
|
const { Header } = Layout;
|
||||||
|
const { Text, Paragraph, Title } = Typography;
|
||||||
|
|
||||||
|
export const TopHeader: React.FC = () => {
|
||||||
|
const pathname = usePathname();
|
||||||
|
const router = useRouter();
|
||||||
|
const { user, notifications } = useAppStore();
|
||||||
|
const { token } = theme.useToken();
|
||||||
|
const [apiMode, setApiMode] = useState<ApiMode | null>(null);
|
||||||
|
const [switchingMode, setSwitchingMode] = useState(false);
|
||||||
|
const [messageApi, contextHolder] = message.useMessage();
|
||||||
|
|
||||||
|
const unreadCount = notifications.filter((notification) => !notification.read).length;
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
BackendApi.getApiMode()
|
||||||
|
.then(setApiMode)
|
||||||
|
.catch(() => setApiMode(null));
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const menuItems = [
|
||||||
|
{ key: '/', icon: <DashboardOutlined />, label: '首页' },
|
||||||
|
{ key: '/video', icon: <VideoCameraOutlined />, label: '视频监控' },
|
||||||
|
{ key: '/machines', icon: <SearchOutlined />, label: '机器查询' },
|
||||||
|
{ key: '/customs', icon: <FileTextOutlined />, label: '报关单' },
|
||||||
|
{ key: '/inspection', icon: <ScanOutlined />, label: '远程查验' },
|
||||||
|
];
|
||||||
|
|
||||||
|
const notificationMenu = {
|
||||||
|
items: notifications.map((notification) => ({
|
||||||
|
key: notification.id,
|
||||||
|
label: (
|
||||||
|
<div style={{ width: 250, padding: '4px 0', whiteSpace: 'normal' }}>
|
||||||
|
<Text strong={!notification.read} type={notification.read ? 'secondary' : undefined} style={{ display: 'block' }}>
|
||||||
|
{notification.title}
|
||||||
|
</Text>
|
||||||
|
<Paragraph style={{ marginTop: 4, marginBottom: 0, color: token.colorTextSecondary, fontSize: 12, lineHeight: 1.5 }}>
|
||||||
|
{notification.message}
|
||||||
|
</Paragraph>
|
||||||
|
<Text style={{ marginTop: 4, display: 'block', color: token.colorTextTertiary, fontSize: 10 }}>
|
||||||
|
{notification.time}
|
||||||
|
</Text>
|
||||||
|
</div>
|
||||||
|
),
|
||||||
|
})),
|
||||||
|
};
|
||||||
|
|
||||||
|
const toggleApiMode = async (nextTestMode: boolean) => {
|
||||||
|
setSwitchingMode(true);
|
||||||
|
try {
|
||||||
|
const nextMode = await BackendApi.setApiMode(nextTestMode);
|
||||||
|
setApiMode(nextMode);
|
||||||
|
messageApi.success(`已切换至${nextMode.label}`);
|
||||||
|
} catch (error) {
|
||||||
|
messageApi.error(error instanceof Error ? error.message : '切换 API 环境失败');
|
||||||
|
} finally {
|
||||||
|
setSwitchingMode(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
{contextHolder}
|
||||||
|
<Header
|
||||||
|
style={{
|
||||||
|
position: 'sticky',
|
||||||
|
top: 0,
|
||||||
|
zIndex: 100,
|
||||||
|
display: 'flex',
|
||||||
|
alignItems: 'center',
|
||||||
|
padding: '0 24px',
|
||||||
|
background: token.colorBgContainer,
|
||||||
|
boxShadow: '0 2px 8px rgba(0, 0, 0, 0.06)',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Space
|
||||||
|
size="middle"
|
||||||
|
onClick={() => router.push('/')}
|
||||||
|
style={{ cursor: 'pointer', marginRight: 48, display: 'flex', alignItems: 'center' }}
|
||||||
|
>
|
||||||
|
<SafetyCertificateOutlined style={{ fontSize: 24, color: token.colorPrimary }} />
|
||||||
|
<Title level={4} style={{ margin: 0, color: token.colorPrimary, whiteSpace: 'nowrap' }}>
|
||||||
|
海关智慧查验平台
|
||||||
|
</Title>
|
||||||
|
</Space>
|
||||||
|
|
||||||
|
<Menu
|
||||||
|
mode="horizontal"
|
||||||
|
selectedKeys={[pathname === '/' ? '/' : `/${pathname.split('/')[1]}`]}
|
||||||
|
items={menuItems}
|
||||||
|
onClick={({ key }) => router.push(key)}
|
||||||
|
style={{ flex: 1, minWidth: 0, borderBottom: 'none', lineHeight: '62px' }}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<Space size="large" style={{ marginLeft: 24, display: 'flex', alignItems: 'center' }}>
|
||||||
|
<Space size={8}>
|
||||||
|
<Text type="secondary" style={{ fontSize: 12 }}>{apiMode?.label ?? '环境'}</Text>
|
||||||
|
<Switch
|
||||||
|
size="small"
|
||||||
|
checked={apiMode?.testMode ?? false}
|
||||||
|
checkedChildren="测"
|
||||||
|
unCheckedChildren="正"
|
||||||
|
loading={switchingMode}
|
||||||
|
onChange={toggleApiMode}
|
||||||
|
/>
|
||||||
|
</Space>
|
||||||
|
|
||||||
|
<Dropdown menu={notificationMenu} placement="bottomRight" trigger={['click']}>
|
||||||
|
<Badge count={unreadCount} size="small" offset={[-4, 4]}>
|
||||||
|
<Button type="text" shape="circle" icon={<BellOutlined style={{ fontSize: 18, color: token.colorText }} />} />
|
||||||
|
</Badge>
|
||||||
|
</Dropdown>
|
||||||
|
|
||||||
|
<Button type="text" shape="circle" icon={<SettingOutlined style={{ fontSize: 18, color: token.colorText }} />} />
|
||||||
|
|
||||||
|
<Space size="small" style={{ cursor: 'pointer', padding: '0 8px', borderRadius: token.borderRadius }}>
|
||||||
|
<Avatar size="small" icon={<UserOutlined />} style={{ backgroundColor: token.colorPrimary }} />
|
||||||
|
<Text style={{ fontSize: 14 }}>{user?.name}</Text>
|
||||||
|
</Space>
|
||||||
|
</Space>
|
||||||
|
</Header>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
};
|
||||||
@@ -0,0 +1,66 @@
|
|||||||
|
type RequestOptions = RequestInit & {
|
||||||
|
query?: Record<string, string | number | boolean | undefined | null>;
|
||||||
|
};
|
||||||
|
|
||||||
|
export class ApiError extends Error {
|
||||||
|
status: number;
|
||||||
|
data: unknown;
|
||||||
|
|
||||||
|
constructor(message: string, status: number, data: unknown) {
|
||||||
|
super(message);
|
||||||
|
this.name = 'ApiError';
|
||||||
|
this.status = status;
|
||||||
|
this.data = data;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const buildUrl = (path: string, query?: RequestOptions['query']) => {
|
||||||
|
const url = new URL(path, typeof window === 'undefined' ? 'http://localhost' : window.location.origin);
|
||||||
|
|
||||||
|
Object.entries(query ?? {}).forEach(([key, value]) => {
|
||||||
|
if (value !== undefined && value !== null && value !== '') {
|
||||||
|
url.searchParams.set(key, String(value));
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
return `${url.pathname}${url.search}`;
|
||||||
|
};
|
||||||
|
|
||||||
|
const parseResponse = async (response: Response) => {
|
||||||
|
const contentType = response.headers.get('content-type') ?? '';
|
||||||
|
if (contentType.includes('application/json')) {
|
||||||
|
return response.json();
|
||||||
|
}
|
||||||
|
|
||||||
|
return response.text();
|
||||||
|
};
|
||||||
|
|
||||||
|
export async function requestJson<T>(path: string, options: RequestOptions = {}): Promise<T> {
|
||||||
|
const { query, headers, body, ...restOptions } = options;
|
||||||
|
const response = await fetch(buildUrl(path, query), {
|
||||||
|
...restOptions,
|
||||||
|
headers: {
|
||||||
|
...(body ? { 'Content-Type': 'application/json' } : {}),
|
||||||
|
...headers,
|
||||||
|
},
|
||||||
|
body,
|
||||||
|
cache: 'no-store',
|
||||||
|
});
|
||||||
|
const data = await parseResponse(response);
|
||||||
|
|
||||||
|
if (!response.ok) {
|
||||||
|
const message = typeof data === 'object' && data && 'error' in data
|
||||||
|
? String((data as { error: unknown }).error)
|
||||||
|
: `请求失败:HTTP ${response.status}`;
|
||||||
|
throw new ApiError(message, response.status, data);
|
||||||
|
}
|
||||||
|
|
||||||
|
return data as T;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function postJson<T>(path: string, payload?: unknown): Promise<T> {
|
||||||
|
return requestJson<T>(path, {
|
||||||
|
method: 'POST',
|
||||||
|
body: payload === undefined ? undefined : JSON.stringify(payload),
|
||||||
|
});
|
||||||
|
}
|
||||||
@@ -0,0 +1,280 @@
|
|||||||
|
import dayjs from 'dayjs';
|
||||||
|
import { postJson, requestJson } from '@/services/apiClient';
|
||||||
|
import {
|
||||||
|
asArray,
|
||||||
|
asRecord,
|
||||||
|
asString,
|
||||||
|
buildCameraList,
|
||||||
|
extractRows,
|
||||||
|
normalizeCustomsDeclaration,
|
||||||
|
normalizeInspection,
|
||||||
|
normalizeInspectionItem,
|
||||||
|
normalizeMachineDetail,
|
||||||
|
normalizeMissionRuntimeState,
|
||||||
|
} from '@/services/normalizers';
|
||||||
|
import type {
|
||||||
|
ActivityItem,
|
||||||
|
ApiMode,
|
||||||
|
CameraInfo,
|
||||||
|
CustomsDeclaration,
|
||||||
|
CustomsStats,
|
||||||
|
InspectionIssue,
|
||||||
|
InspectionState,
|
||||||
|
MachineDetail,
|
||||||
|
MissionStateResponse,
|
||||||
|
SystemStatus,
|
||||||
|
} from '@/types';
|
||||||
|
|
||||||
|
interface OkResponse<T> {
|
||||||
|
ok?: boolean;
|
||||||
|
data?: T;
|
||||||
|
error?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
const ensureOk = <T extends OkResponse<unknown>>(payload: T, fallbackMessage: string) => {
|
||||||
|
if (payload.ok === false) {
|
||||||
|
throw new Error(payload.error || fallbackMessage);
|
||||||
|
}
|
||||||
|
|
||||||
|
return payload;
|
||||||
|
};
|
||||||
|
|
||||||
|
export const BackendApi = {
|
||||||
|
async getSystemStatus(): Promise<SystemStatus> {
|
||||||
|
const [statusPayload, capabilitiesPayload] = await Promise.all([
|
||||||
|
requestJson<Record<string, unknown>>('/api/status'),
|
||||||
|
requestJson<Record<string, unknown>>('/api/camera/capabilities').catch((): Record<string, unknown> => ({})),
|
||||||
|
]);
|
||||||
|
|
||||||
|
return {
|
||||||
|
state: asString(statusPayload.state, 'idle') as SystemStatus['state'],
|
||||||
|
agvConnected: Boolean(statusPayload.agv_connected),
|
||||||
|
armConnected: Boolean(statusPayload.arm_connected),
|
||||||
|
cameraOpened: Boolean(statusPayload.camera_opened),
|
||||||
|
armCameraOpened: Boolean(statusPayload.arm_camera_opened),
|
||||||
|
mapLoaded: Boolean(statusPayload.map_loaded),
|
||||||
|
pointsCount: Number(statusPayload.points_count ?? 0),
|
||||||
|
modelsCount: Number(statusPayload.models_count ?? 0),
|
||||||
|
machinesCount: Number(statusPayload.machines_count ?? 0),
|
||||||
|
hasAgvCamera: Boolean(statusPayload.has_agv_camera ?? capabilitiesPayload.has_agv_camera ?? statusPayload.camera_opened),
|
||||||
|
hasArmCamera: Boolean(statusPayload.has_arm_camera ?? capabilitiesPayload.has_arm_camera ?? statusPayload.arm_camera_opened),
|
||||||
|
};
|
||||||
|
},
|
||||||
|
|
||||||
|
async connectAll() {
|
||||||
|
return requestJson<{ agv: boolean; arm: boolean; camera: boolean; errors?: string[] }>('/api/system/connect', {
|
||||||
|
method: 'POST',
|
||||||
|
});
|
||||||
|
},
|
||||||
|
|
||||||
|
async disconnectAll() {
|
||||||
|
return postJson<{ ok: boolean }>('/api/system/disconnect');
|
||||||
|
},
|
||||||
|
|
||||||
|
async connectDevice(device: 'agv' | 'arm' | 'camera' | 'arm_camera') {
|
||||||
|
return postJson<{ device: string; ok: boolean; error?: string }>('/api/device/connect', { device });
|
||||||
|
},
|
||||||
|
|
||||||
|
async getApiMode(): Promise<ApiMode> {
|
||||||
|
const payload = ensureOk(await requestJson<OkResponse<unknown> & Record<string, unknown>>('/api/config/mode'), '读取 API 环境失败');
|
||||||
|
return {
|
||||||
|
testMode: Boolean(payload.test_mode),
|
||||||
|
baseUrl: asString(payload.base_url),
|
||||||
|
label: asString(payload.label, Boolean(payload.test_mode) ? '测试环境' : '正式环境'),
|
||||||
|
};
|
||||||
|
},
|
||||||
|
|
||||||
|
async setApiMode(testMode: boolean): Promise<ApiMode> {
|
||||||
|
const payload = ensureOk(await postJson<OkResponse<unknown> & Record<string, unknown>>('/api/config/mode', { test_mode: testMode }), '切换 API 环境失败');
|
||||||
|
return {
|
||||||
|
testMode: Boolean(payload.test_mode),
|
||||||
|
baseUrl: asString(payload.base_url),
|
||||||
|
label: asString(payload.label, testMode ? '测试环境' : '正式环境'),
|
||||||
|
};
|
||||||
|
},
|
||||||
|
|
||||||
|
async getCustomsList(pageNum = 1, pageSize = 50): Promise<CustomsDeclaration[]> {
|
||||||
|
const payload = ensureOk(
|
||||||
|
await requestJson<OkResponse<unknown> & Record<string, unknown>>('/api/customs/list', {
|
||||||
|
query: { pageNum, pageSize },
|
||||||
|
}),
|
||||||
|
'报关单列表加载失败',
|
||||||
|
);
|
||||||
|
const rows = extractRows(payload);
|
||||||
|
return rows.map(normalizeCustomsDeclaration);
|
||||||
|
},
|
||||||
|
|
||||||
|
async getCustomsMachines(customsId: string) {
|
||||||
|
const payload = ensureOk(
|
||||||
|
await requestJson<OkResponse<unknown> & Record<string, unknown>>('/api/customs/machines', {
|
||||||
|
query: { customsId },
|
||||||
|
}),
|
||||||
|
'机器列表加载失败',
|
||||||
|
);
|
||||||
|
return extractRows(payload).map(normalizeInspectionItem);
|
||||||
|
},
|
||||||
|
|
||||||
|
async getCustomsById(customsId: string): Promise<CustomsDeclaration | null> {
|
||||||
|
const list = await this.getCustomsList(1, 100);
|
||||||
|
const found = list.find((item) => item.customsId === customsId || item.id === customsId || item.customsName === customsId);
|
||||||
|
if (!found) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
const items = await this.getCustomsMachines(found.id).catch(() => found.items);
|
||||||
|
return {
|
||||||
|
...found,
|
||||||
|
items,
|
||||||
|
machineCount: found.machineCount || items.reduce((sum, item) => sum + item.quantify, 0),
|
||||||
|
};
|
||||||
|
},
|
||||||
|
|
||||||
|
async startCustomsInspection(customs: Pick<CustomsDeclaration, 'id' | 'customsName'>): Promise<InspectionState> {
|
||||||
|
const payload = ensureOk(
|
||||||
|
await postJson<OkResponse<unknown> & Record<string, unknown>>('/api/customs/inspection/start', {
|
||||||
|
customsId: customs.id,
|
||||||
|
customsName: customs.customsName,
|
||||||
|
}),
|
||||||
|
'开始查验失败',
|
||||||
|
);
|
||||||
|
const inspection = normalizeInspection(payload.inspection, 'running');
|
||||||
|
if (!inspection) {
|
||||||
|
throw new Error('后端未返回查验状态');
|
||||||
|
}
|
||||||
|
|
||||||
|
return inspection;
|
||||||
|
},
|
||||||
|
|
||||||
|
async getCurrentInspection(): Promise<InspectionState | null> {
|
||||||
|
const payload = ensureOk(await requestJson<OkResponse<unknown> & Record<string, unknown>>('/api/customs/inspection'), '查验状态加载失败');
|
||||||
|
return normalizeInspection(payload.inspection, 'running');
|
||||||
|
},
|
||||||
|
|
||||||
|
async endCustomsInspection() {
|
||||||
|
return postJson<{ ok: boolean }>('/api/customs/inspection/end');
|
||||||
|
},
|
||||||
|
|
||||||
|
async getMissionState(): Promise<MissionStateResponse> {
|
||||||
|
const payload = await requestJson<Record<string, unknown>>('/api/mission/state');
|
||||||
|
const runtimeStatus = normalizeMissionRuntimeState(payload.state);
|
||||||
|
return {
|
||||||
|
state: asString(payload.state, 'idle') as MissionStateResponse['state'],
|
||||||
|
inspection: normalizeInspection(payload.inspection, runtimeStatus),
|
||||||
|
rows: Number(payload.rows ?? 0),
|
||||||
|
cols: Number(payload.cols ?? 0),
|
||||||
|
tasks: asArray(payload.tasks),
|
||||||
|
errorMsg: asString(payload.error_msg),
|
||||||
|
waitingStep: Boolean(payload.waiting_step),
|
||||||
|
waitingError: Boolean(payload.waiting_error),
|
||||||
|
};
|
||||||
|
},
|
||||||
|
|
||||||
|
async startMission() {
|
||||||
|
return ensureOk(await postJson<OkResponse<unknown> & Record<string, unknown>>('/api/mission/start', {}), '启动自动任务失败');
|
||||||
|
},
|
||||||
|
|
||||||
|
async pauseMission() {
|
||||||
|
return postJson<{ ok: boolean }>('/api/mission/pause');
|
||||||
|
},
|
||||||
|
|
||||||
|
async resumeMission() {
|
||||||
|
return postJson<{ ok: boolean }>('/api/mission/resume');
|
||||||
|
},
|
||||||
|
|
||||||
|
async stopMission() {
|
||||||
|
return postJson<{ ok: boolean }>('/api/mission/stop');
|
||||||
|
},
|
||||||
|
|
||||||
|
async getMissionLogs(): Promise<ActivityItem[]> {
|
||||||
|
const payload = await requestJson<Record<string, unknown>>('/api/mission/log');
|
||||||
|
const logItems = asArray(payload.log);
|
||||||
|
return logItems.slice(-80).map((item, index) => {
|
||||||
|
const record = asRecord(item);
|
||||||
|
const message = asString(record.msg ?? record.message ?? item, '系统日志');
|
||||||
|
const time = asString(record.time, dayjs().format('HH:mm:ss'));
|
||||||
|
const lowered = message.toLowerCase();
|
||||||
|
const type: ActivityItem['type'] = lowered.includes('error') || message.includes('失败')
|
||||||
|
? 'warning'
|
||||||
|
: message.includes('完成') || lowered.includes('success')
|
||||||
|
? 'success'
|
||||||
|
: 'info';
|
||||||
|
return {
|
||||||
|
id: asString(record.id, `log-${index}`),
|
||||||
|
time,
|
||||||
|
type,
|
||||||
|
message,
|
||||||
|
};
|
||||||
|
});
|
||||||
|
},
|
||||||
|
|
||||||
|
async getCameras(): Promise<CameraInfo[]> {
|
||||||
|
const [statusPayload, capabilitiesPayload] = await Promise.all([
|
||||||
|
requestJson<Record<string, unknown>>('/api/status').catch((): Record<string, unknown> => ({})),
|
||||||
|
requestJson<Record<string, unknown>>('/api/camera/capabilities').catch((): Record<string, unknown> => ({})),
|
||||||
|
]);
|
||||||
|
return buildCameraList(statusPayload, capabilitiesPayload);
|
||||||
|
},
|
||||||
|
|
||||||
|
async getMachineDetail(serialNumber: string): Promise<MachineDetail> {
|
||||||
|
const payload = await requestJson<Record<string, unknown>>('/api/customs/printer', {
|
||||||
|
query: { serialNumber },
|
||||||
|
});
|
||||||
|
ensureOk(payload, '机器详情加载失败');
|
||||||
|
return normalizeMachineDetail(serialNumber, payload);
|
||||||
|
},
|
||||||
|
|
||||||
|
async getCustomsStats(): Promise<CustomsStats> {
|
||||||
|
const [customsList, inspection] = await Promise.all([
|
||||||
|
this.getCustomsList(1, 50),
|
||||||
|
this.getCurrentInspection().catch(() => null),
|
||||||
|
]);
|
||||||
|
|
||||||
|
return {
|
||||||
|
pendingCount: customsList.filter((item) => item.status === 'pending').length,
|
||||||
|
releasedToday: customsList.filter((item) => item.status === 'released' && item.createdAt.startsWith(dayjs().format('YYYY-MM-DD'))).length,
|
||||||
|
inspectingCount: inspection ? 1 : customsList.filter((item) => item.status === 'inspecting').length,
|
||||||
|
abnormalCount: customsList.filter((item) => item.status === 'abnormal').length,
|
||||||
|
};
|
||||||
|
},
|
||||||
|
|
||||||
|
async getRecentActivities(): Promise<ActivityItem[]> {
|
||||||
|
const [logs, inspection] = await Promise.all([
|
||||||
|
this.getMissionLogs().catch(() => []),
|
||||||
|
this.getCurrentInspection().catch(() => null),
|
||||||
|
]);
|
||||||
|
|
||||||
|
if (logs.length) {
|
||||||
|
return logs.slice(-5).reverse();
|
||||||
|
}
|
||||||
|
|
||||||
|
if (inspection) {
|
||||||
|
return [
|
||||||
|
{
|
||||||
|
id: 'current-inspection',
|
||||||
|
time: inspection.startedAt ? dayjs.unix(inspection.startedAt).format('HH:mm') : dayjs().format('HH:mm'),
|
||||||
|
type: 'start',
|
||||||
|
message: `${inspection.customsName || inspection.customsId} 查验中`,
|
||||||
|
},
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
return [];
|
||||||
|
},
|
||||||
|
|
||||||
|
async getInspectionIssues(): Promise<InspectionIssue[]> {
|
||||||
|
const missionState = await this.getMissionState().catch(() => null);
|
||||||
|
if (missionState?.waitingError && missionState.errorMsg) {
|
||||||
|
return [
|
||||||
|
{
|
||||||
|
id: 'mission-error',
|
||||||
|
time: dayjs().format('HH:mm:ss'),
|
||||||
|
description: missionState.errorMsg,
|
||||||
|
severity: 'error',
|
||||||
|
status: 'pending',
|
||||||
|
},
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
return [];
|
||||||
|
},
|
||||||
|
};
|
||||||
@@ -0,0 +1,289 @@
|
|||||||
|
import dayjs from 'dayjs';
|
||||||
|
import type {
|
||||||
|
CameraInfo,
|
||||||
|
CustomsDeclaration,
|
||||||
|
CustomsStatus,
|
||||||
|
InspectionItem,
|
||||||
|
InspectionState,
|
||||||
|
MachineDetail,
|
||||||
|
MachineImageItem,
|
||||||
|
MissionRuntimeState,
|
||||||
|
} from '@/types';
|
||||||
|
|
||||||
|
type UnknownRecord = Record<string, unknown>;
|
||||||
|
|
||||||
|
export const asRecord = (value: unknown): UnknownRecord => {
|
||||||
|
if (value && typeof value === 'object' && !Array.isArray(value)) {
|
||||||
|
return value as UnknownRecord;
|
||||||
|
}
|
||||||
|
|
||||||
|
return {};
|
||||||
|
};
|
||||||
|
|
||||||
|
export const asArray = (value: unknown): unknown[] => {
|
||||||
|
if (Array.isArray(value)) {
|
||||||
|
return value;
|
||||||
|
}
|
||||||
|
|
||||||
|
return [];
|
||||||
|
};
|
||||||
|
|
||||||
|
export const asString = (value: unknown, fallback = '') => {
|
||||||
|
if (typeof value === 'string') {
|
||||||
|
return value;
|
||||||
|
}
|
||||||
|
if (typeof value === 'number' || typeof value === 'boolean') {
|
||||||
|
return String(value);
|
||||||
|
}
|
||||||
|
|
||||||
|
return fallback;
|
||||||
|
};
|
||||||
|
|
||||||
|
export const asNumber = (value: unknown, fallback = 0) => {
|
||||||
|
if (typeof value === 'number' && Number.isFinite(value)) {
|
||||||
|
return value;
|
||||||
|
}
|
||||||
|
if (typeof value === 'string' && value.trim()) {
|
||||||
|
const parsed = Number(value);
|
||||||
|
return Number.isFinite(parsed) ? parsed : fallback;
|
||||||
|
}
|
||||||
|
|
||||||
|
return fallback;
|
||||||
|
};
|
||||||
|
|
||||||
|
const pickString = (record: UnknownRecord, keys: string[], fallback = '') => {
|
||||||
|
for (const key of keys) {
|
||||||
|
const value = record[key];
|
||||||
|
if (value !== undefined && value !== null && value !== '') {
|
||||||
|
return asString(value, fallback);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return fallback;
|
||||||
|
};
|
||||||
|
|
||||||
|
const pickNumber = (record: UnknownRecord, keys: string[], fallback = 0) => {
|
||||||
|
for (const key of keys) {
|
||||||
|
const value = record[key];
|
||||||
|
if (value !== undefined && value !== null && value !== '') {
|
||||||
|
return asNumber(value, fallback);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return fallback;
|
||||||
|
};
|
||||||
|
|
||||||
|
const normalizeDateTime = (value: unknown) => {
|
||||||
|
if (!value) {
|
||||||
|
return '-';
|
||||||
|
}
|
||||||
|
const text = asString(value);
|
||||||
|
const parsed = dayjs(text);
|
||||||
|
return parsed.isValid() ? parsed.format('YYYY-MM-DD HH:mm') : text;
|
||||||
|
};
|
||||||
|
|
||||||
|
const normalizeStatus = (raw: unknown, hasCustomsCode = false): CustomsStatus => {
|
||||||
|
const status = asString(raw).toLowerCase();
|
||||||
|
if (['released', 'finish', 'finished', 'completed', 'done', 'pass', 'passed'].includes(status)) {
|
||||||
|
return 'released';
|
||||||
|
}
|
||||||
|
if (['abnormal', 'error', 'failed', 'exception'].includes(status)) {
|
||||||
|
return 'abnormal';
|
||||||
|
}
|
||||||
|
if (['inspecting', 'running', 'processing'].includes(status)) {
|
||||||
|
return 'inspecting';
|
||||||
|
}
|
||||||
|
|
||||||
|
return hasCustomsCode ? 'pending' : 'pending';
|
||||||
|
};
|
||||||
|
|
||||||
|
export const extractRows = (payload: unknown): unknown[] => {
|
||||||
|
const root = asRecord(payload);
|
||||||
|
const data = asRecord(root.data);
|
||||||
|
const nestedData = asRecord(data.data);
|
||||||
|
|
||||||
|
if (Array.isArray(payload)) return payload;
|
||||||
|
if (Array.isArray(root.rows)) return root.rows;
|
||||||
|
if (Array.isArray(root.records)) return root.records;
|
||||||
|
if (Array.isArray(root.data)) return root.data;
|
||||||
|
if (Array.isArray(data.rows)) return data.rows;
|
||||||
|
if (Array.isArray(data.records)) return data.records;
|
||||||
|
if (Array.isArray(data.data)) return data.data;
|
||||||
|
if (Array.isArray(nestedData.rows)) return nestedData.rows;
|
||||||
|
if (Array.isArray(nestedData.records)) return nestedData.records;
|
||||||
|
|
||||||
|
return [];
|
||||||
|
};
|
||||||
|
|
||||||
|
export const normalizeInspectionItem = (value: unknown): InspectionItem => {
|
||||||
|
const item = asRecord(value);
|
||||||
|
return {
|
||||||
|
inventoryCode: pickString(item, ['inventoryCode', 'machineCode', 'code'], '-'),
|
||||||
|
inventoryName: pickString(item, ['inventoryName', 'machineName', 'name', 'modelName'], '-'),
|
||||||
|
spec: pickString(item, ['spec', 'inventorySpecification', 'specification'], '-'),
|
||||||
|
quantify: pickNumber(item, ['quantify', 'quantity', 'count'], 0),
|
||||||
|
inspected: pickNumber(item, ['inspected', 'inspectionCount', 'checkedCount'], 0),
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
|
export const normalizeCustomsDeclaration = (value: unknown): CustomsDeclaration => {
|
||||||
|
const row = asRecord(value);
|
||||||
|
const customs = asRecord(row.customs);
|
||||||
|
const customsId = pickString(customs, ['id'], pickString(row, ['customsId', 'customs_id', 'id']));
|
||||||
|
const customsCode = pickString(customs, ['customsCode'], pickString(row, ['customsCode', 'orderCode', 'drawCode'], customsId || '-'));
|
||||||
|
const orderIds = pickString(customs, ['orderId']);
|
||||||
|
const machineCount = pickNumber(row, ['machineCount', 'machine_count'], orderIds ? orderIds.split(',').filter(Boolean).length : 0);
|
||||||
|
const rawItems = asArray(row.items);
|
||||||
|
const hasCustomsCode = Boolean(pickString(customs, ['customsCode'], pickString(row, ['customsCode'])));
|
||||||
|
|
||||||
|
return {
|
||||||
|
id: customsId || customsCode,
|
||||||
|
customsId: customsId || customsCode,
|
||||||
|
customsName: customsCode,
|
||||||
|
status: normalizeStatus(pickString(row, ['status', 'state']), hasCustomsCode),
|
||||||
|
machineCount,
|
||||||
|
createdAt: normalizeDateTime(pickString(row, ['createTime', 'createdAt', 'applyTime', 'updateTime'], pickString(row, ['orderCode', 'drawCode']))),
|
||||||
|
items: rawItems.map(normalizeInspectionItem),
|
||||||
|
raw: value,
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
|
export const normalizeInspection = (value: unknown, runtimeState: MissionRuntimeState = 'idle'): InspectionState | null => {
|
||||||
|
const inspection = asRecord(value);
|
||||||
|
const customsId = pickString(inspection, ['customsId', 'customs_id', 'id']);
|
||||||
|
if (!customsId && !inspection.items) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
customsId,
|
||||||
|
customsName: pickString(inspection, ['customsName', 'customs_name', 'name'], customsId),
|
||||||
|
status: runtimeState,
|
||||||
|
items: asArray(inspection.items).map(normalizeInspectionItem),
|
||||||
|
startedAt: pickNumber(inspection, ['startedAt', 'started_at'], 0),
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
|
export const normalizeMissionRuntimeState = (state: unknown): MissionRuntimeState => {
|
||||||
|
const text = asString(state).toLowerCase();
|
||||||
|
if (text === 'running' || text === 'setting') return 'running';
|
||||||
|
if (text === 'paused') return 'paused';
|
||||||
|
if (text === 'completed') return 'completed';
|
||||||
|
return 'idle';
|
||||||
|
};
|
||||||
|
|
||||||
|
const normalizeImage = (value: unknown, index: number, fallbackName: string): MachineImageItem => {
|
||||||
|
const item = asRecord(value);
|
||||||
|
const url = pickString(item, ['url', 'imageUrl', 'path', 'fileUrl']);
|
||||||
|
return {
|
||||||
|
id: pickString(item, ['id'], `${fallbackName}-${index}`),
|
||||||
|
url,
|
||||||
|
thumbnailUrl: pickString(item, ['thumbnailUrl', 'thumbUrl'], url),
|
||||||
|
name: pickString(item, ['name', 'fileName'], `${fallbackName} ${index + 1}`),
|
||||||
|
createdAt: normalizeDateTime(pickString(item, ['createdAt', 'createTime', 'uploadTime'])),
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
|
const emptyImageGroups = () => ({
|
||||||
|
incomingInspection: [] as MachineImageItem[],
|
||||||
|
startupTestSample: [] as MachineImageItem[],
|
||||||
|
productionOrder: [] as MachineImageItem[],
|
||||||
|
robotInspection: [] as MachineImageItem[],
|
||||||
|
});
|
||||||
|
|
||||||
|
export const normalizeMachineDetail = (serialNumber: string, payload: unknown): MachineDetail => {
|
||||||
|
const root = asRecord(payload);
|
||||||
|
const printer = asRecord(root.printer ?? asRecord(root.data).printer);
|
||||||
|
const orderItem = asRecord(root.orderItem ?? asRecord(root.data).orderItem);
|
||||||
|
const inventory = asRecord(orderItem.inventory ?? printer.inventory);
|
||||||
|
const modelName = pickString(root, ['modelName'], pickString(inventory, ['inventoryName', 'name'], pickString(printer, ['model', 'machineModel'], '未知设备')));
|
||||||
|
const images = emptyImageGroups();
|
||||||
|
const rawImages = asRecord(root.images ?? printer.images);
|
||||||
|
|
||||||
|
images.incomingInspection = asArray(rawImages.incomingInspection).map((item, index) => normalizeImage(item, index, '来料检验单'));
|
||||||
|
images.startupTestSample = asArray(rawImages.startupTestSample).map((item, index) => normalizeImage(item, index, '开机测试样张'));
|
||||||
|
images.productionOrder = asArray(rawImages.productionOrder).map((item, index) => normalizeImage(item, index, '生产加工单'));
|
||||||
|
images.robotInspection = asArray(rawImages.robotInspection).map((item, index) => normalizeImage(item, index, '机器人查验拍照'));
|
||||||
|
|
||||||
|
return {
|
||||||
|
serialNumber,
|
||||||
|
modelName,
|
||||||
|
modelId: pickString(inventory, ['inventoryCode', 'code'], pickString(root, ['inventoryCode'], '-')),
|
||||||
|
customsId: pickString(orderItem, ['customsId'], '-'),
|
||||||
|
customsName: pickString(orderItem, ['customsName'], '-'),
|
||||||
|
status: 'pending',
|
||||||
|
specs: {
|
||||||
|
物料编码: pickString(inventory, ['inventoryCode', 'code'], '-'),
|
||||||
|
规格: pickString(inventory, ['inventorySpecification', 'specification', 'spec'], '-'),
|
||||||
|
序列号: pickString(printer, ['serialNumber'], serialNumber),
|
||||||
|
},
|
||||||
|
createdAt: normalizeDateTime(pickString(printer, ['createTime', 'createdAt'])),
|
||||||
|
images,
|
||||||
|
inspectionRecords: [],
|
||||||
|
raw: payload,
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
|
export const buildCameraList = (statusPayload: unknown, capabilitiesPayload: unknown): CameraInfo[] => {
|
||||||
|
const status = asRecord(statusPayload);
|
||||||
|
const capabilities = asRecord(capabilitiesPayload);
|
||||||
|
const hasAgvCamera = Boolean(status.has_agv_camera ?? capabilities.has_agv_camera ?? status.camera_opened);
|
||||||
|
const hasArmCamera = Boolean(status.has_arm_camera ?? capabilities.has_arm_camera ?? status.arm_camera_opened);
|
||||||
|
|
||||||
|
return [
|
||||||
|
{
|
||||||
|
id: 'overview-1',
|
||||||
|
name: '监控摄像头 1',
|
||||||
|
location: '查验区东侧',
|
||||||
|
streamUrl: '',
|
||||||
|
status: 'offline',
|
||||||
|
category: 'overview',
|
||||||
|
placeholder: true,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'overview-2',
|
||||||
|
name: '监控摄像头 2',
|
||||||
|
location: '查验区南侧',
|
||||||
|
streamUrl: '',
|
||||||
|
status: 'offline',
|
||||||
|
category: 'overview',
|
||||||
|
placeholder: true,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'overview-3',
|
||||||
|
name: '监控摄像头 3',
|
||||||
|
location: '查验区西侧',
|
||||||
|
streamUrl: '',
|
||||||
|
status: 'offline',
|
||||||
|
category: 'overview',
|
||||||
|
placeholder: true,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'overview-4',
|
||||||
|
name: '监控摄像头 4',
|
||||||
|
location: '查验区北侧',
|
||||||
|
streamUrl: '',
|
||||||
|
status: 'offline',
|
||||||
|
category: 'overview',
|
||||||
|
placeholder: true,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'agv-camera',
|
||||||
|
name: 'AGV 主摄像头',
|
||||||
|
location: 'AGV 前端',
|
||||||
|
streamUrl: '/api/camera/refresh',
|
||||||
|
status: hasAgvCamera && Boolean(status.camera_opened) ? 'online' : 'offline',
|
||||||
|
category: 'agv',
|
||||||
|
placeholder: !hasAgvCamera,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'arm-camera',
|
||||||
|
name: '作业视角',
|
||||||
|
location: '机械臂',
|
||||||
|
streamUrl: '/api/camera/arm_preview',
|
||||||
|
status: hasArmCamera && Boolean(status.arm_camera_opened) ? 'online' : 'offline',
|
||||||
|
category: 'operation',
|
||||||
|
placeholder: !hasArmCamera,
|
||||||
|
},
|
||||||
|
];
|
||||||
|
};
|
||||||
@@ -0,0 +1,39 @@
|
|||||||
|
'use client';
|
||||||
|
|
||||||
|
import { create } from 'zustand';
|
||||||
|
import type { CustomsDeclaration, InspectionState, Notification, User } from '@/types';
|
||||||
|
|
||||||
|
interface AppState {
|
||||||
|
user: User | null;
|
||||||
|
notifications: Notification[];
|
||||||
|
selectedCustoms: CustomsDeclaration | null;
|
||||||
|
inspection: InspectionState | null;
|
||||||
|
setUser: (user: User | null) => void;
|
||||||
|
addNotification: (notification: Notification) => void;
|
||||||
|
markNotificationRead: (id: string) => void;
|
||||||
|
setSelectedCustoms: (customs: CustomsDeclaration | null) => void;
|
||||||
|
setInspection: (inspection: InspectionState | null) => void;
|
||||||
|
updateInspectionStatus: (status: InspectionState['status']) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const useAppStore = create<AppState>((set) => ({
|
||||||
|
user: { name: '张三', role: '海关查验员' },
|
||||||
|
notifications: [
|
||||||
|
{ id: '1', title: '系统通知', message: '远程查验前端已连接真实后端接口', time: '当前', read: false },
|
||||||
|
],
|
||||||
|
selectedCustoms: null,
|
||||||
|
inspection: null,
|
||||||
|
|
||||||
|
setUser: (user) => set({ user }),
|
||||||
|
addNotification: (notification) => set((state) => ({ notifications: [notification, ...state.notifications] })),
|
||||||
|
markNotificationRead: (id) => set((state) => ({
|
||||||
|
notifications: state.notifications.map((notification) => (
|
||||||
|
notification.id === id ? { ...notification, read: true } : notification
|
||||||
|
)),
|
||||||
|
})),
|
||||||
|
setSelectedCustoms: (selectedCustoms) => set({ selectedCustoms }),
|
||||||
|
setInspection: (inspection) => set({ inspection }),
|
||||||
|
updateInspectionStatus: (status) => set((state) => ({
|
||||||
|
inspection: state.inspection ? { ...state.inspection, status } : null,
|
||||||
|
})),
|
||||||
|
}));
|
||||||
@@ -0,0 +1,150 @@
|
|||||||
|
export interface User {
|
||||||
|
name: string;
|
||||||
|
role: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface Notification {
|
||||||
|
id: string;
|
||||||
|
title: string;
|
||||||
|
message: string;
|
||||||
|
time: string;
|
||||||
|
read: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
export type CustomsStatus = 'pending' | 'released' | 'abnormal' | 'inspecting';
|
||||||
|
export type MissionRuntimeState = 'idle' | 'running' | 'paused' | 'completed';
|
||||||
|
export type DeviceStatus = 'online' | 'offline';
|
||||||
|
|
||||||
|
export interface InspectionItem {
|
||||||
|
inventoryCode: string;
|
||||||
|
inventoryName: string;
|
||||||
|
spec: string;
|
||||||
|
quantify: number;
|
||||||
|
inspected: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface CustomsDeclaration {
|
||||||
|
id: string;
|
||||||
|
customsId: string;
|
||||||
|
customsName: string;
|
||||||
|
status: CustomsStatus;
|
||||||
|
machineCount: number;
|
||||||
|
createdAt: string;
|
||||||
|
items: InspectionItem[];
|
||||||
|
raw?: unknown;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface CustomsStats {
|
||||||
|
pendingCount: number;
|
||||||
|
releasedToday: number;
|
||||||
|
inspectingCount: number;
|
||||||
|
abnormalCount: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ActivityItem {
|
||||||
|
id: string;
|
||||||
|
time: string;
|
||||||
|
type: 'start' | 'success' | 'info' | 'warning';
|
||||||
|
message: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface CameraInfo {
|
||||||
|
id: string;
|
||||||
|
name: string;
|
||||||
|
location: string;
|
||||||
|
streamUrl: string;
|
||||||
|
status: DeviceStatus;
|
||||||
|
category: 'overview' | 'agv' | 'operation';
|
||||||
|
placeholder?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface InspectionIssue {
|
||||||
|
id: string;
|
||||||
|
time: string;
|
||||||
|
description: string;
|
||||||
|
severity: 'warning' | 'error';
|
||||||
|
status: 'pending' | 'disposed' | 'cancelled';
|
||||||
|
disposedAt?: string;
|
||||||
|
disposedBy?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface InspectionState {
|
||||||
|
customsId: string;
|
||||||
|
customsName: string;
|
||||||
|
status: MissionRuntimeState;
|
||||||
|
items: InspectionItem[];
|
||||||
|
startedAt: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface MachineImageItem {
|
||||||
|
id: string;
|
||||||
|
url: string;
|
||||||
|
thumbnailUrl: string;
|
||||||
|
name: string;
|
||||||
|
createdAt: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface InspectionRecord {
|
||||||
|
id: string;
|
||||||
|
time: string;
|
||||||
|
operator: string;
|
||||||
|
result: string;
|
||||||
|
remark: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface MachineDetail {
|
||||||
|
serialNumber: string;
|
||||||
|
modelName: string;
|
||||||
|
modelId: string;
|
||||||
|
customsId: string;
|
||||||
|
customsName: string;
|
||||||
|
status: CustomsStatus;
|
||||||
|
specs: Record<string, string>;
|
||||||
|
createdAt: string;
|
||||||
|
images: {
|
||||||
|
incomingInspection: MachineImageItem[];
|
||||||
|
startupTestSample: MachineImageItem[];
|
||||||
|
productionOrder: MachineImageItem[];
|
||||||
|
robotInspection: MachineImageItem[];
|
||||||
|
};
|
||||||
|
inspectionRecords: InspectionRecord[];
|
||||||
|
raw?: unknown;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface SystemStatus {
|
||||||
|
state: 'idle' | 'setting' | 'running' | 'paused';
|
||||||
|
agvConnected: boolean;
|
||||||
|
armConnected: boolean;
|
||||||
|
cameraOpened: boolean;
|
||||||
|
armCameraOpened: boolean;
|
||||||
|
mapLoaded: boolean;
|
||||||
|
pointsCount: number;
|
||||||
|
modelsCount: number;
|
||||||
|
machinesCount: number;
|
||||||
|
hasAgvCamera: boolean;
|
||||||
|
hasArmCamera: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface MissionStateResponse {
|
||||||
|
state: 'idle' | 'setting' | 'running' | 'paused';
|
||||||
|
inspection: InspectionState | null;
|
||||||
|
rows?: number;
|
||||||
|
cols?: number;
|
||||||
|
tasks?: unknown[];
|
||||||
|
log?: unknown[];
|
||||||
|
errorMsg?: string;
|
||||||
|
waitingStep?: boolean;
|
||||||
|
waitingError?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ApiMode {
|
||||||
|
testMode: boolean;
|
||||||
|
baseUrl: string;
|
||||||
|
label: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface RecentMachineQuery {
|
||||||
|
serialNumber: string;
|
||||||
|
name: string;
|
||||||
|
time: string;
|
||||||
|
}
|
||||||
@@ -0,0 +1,26 @@
|
|||||||
|
{
|
||||||
|
"compilerOptions": {
|
||||||
|
"lib": ["dom", "dom.iterable", "esnext"],
|
||||||
|
"allowJs": true,
|
||||||
|
"skipLibCheck": true,
|
||||||
|
"strict": true,
|
||||||
|
"noEmit": true,
|
||||||
|
"esModuleInterop": true,
|
||||||
|
"module": "esnext",
|
||||||
|
"moduleResolution": "bundler",
|
||||||
|
"resolveJsonModule": true,
|
||||||
|
"isolatedModules": true,
|
||||||
|
"jsx": "preserve",
|
||||||
|
"incremental": true,
|
||||||
|
"plugins": [
|
||||||
|
{
|
||||||
|
"name": "next"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"paths": {
|
||||||
|
"@/*": ["./src/*"]
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"include": ["next-env.d.ts", "**/*.ts", "**/*.tsx", ".next/types/**/*.ts"],
|
||||||
|
"exclude": ["node_modules"]
|
||||||
|
}
|
||||||
@@ -0,0 +1,21 @@
|
|||||||
|
[project]
|
||||||
|
name = "smart-inspection"
|
||||||
|
version = "0.1.0"
|
||||||
|
description = "AGV 智能巡检系统与机械臂服务端"
|
||||||
|
readme = "scripts/README.md"
|
||||||
|
requires-python = ">=3.10,<3.11"
|
||||||
|
dependencies = [
|
||||||
|
"flask>=2.0,<2.3",
|
||||||
|
"flask-cors>=3.0",
|
||||||
|
"numpy>=1.20",
|
||||||
|
"opencv-python>=4.5",
|
||||||
|
"pillow>=10.0",
|
||||||
|
"pymycobot>=4.0.0",
|
||||||
|
"pyyaml>=6.0",
|
||||||
|
"pyzbar>=0.1.8",
|
||||||
|
"requests>=2.25",
|
||||||
|
"werkzeug>=2.2,<3.0",
|
||||||
|
]
|
||||||
|
|
||||||
|
[tool.uv]
|
||||||
|
package = false
|
||||||
@@ -1,31 +0,0 @@
|
|||||||
#!/bin/bash
|
|
||||||
cd /home/elephant/work/agv_app
|
|
||||||
|
|
||||||
# 语法检查
|
|
||||||
python3 -m py_compile app.py
|
|
||||||
if [ $? -ne 0 ]; then
|
|
||||||
echo "Syntax error!"
|
|
||||||
exit 1
|
|
||||||
fi
|
|
||||||
|
|
||||||
# 重启服务
|
|
||||||
pkill -f "python.*app.py" 2>/dev/null
|
|
||||||
sleep 1
|
|
||||||
nohup python3 app.py > app.log 2>&1 &
|
|
||||||
sleep 3
|
|
||||||
|
|
||||||
# 验证
|
|
||||||
if ss -tlnp | grep 5000; then
|
|
||||||
echo "✓ 端口5000 正常"
|
|
||||||
# 测试机械臂单帧
|
|
||||||
result=$(curl -s --max-time 5 http://127.0.0.1:5000/api/camera/arm_refresh | head -c 4)
|
|
||||||
echo -n "arm_refresh: "
|
|
||||||
if [ "$result" = "$(echo -en '\xff\xd8\xff\xe0')" ]; then
|
|
||||||
echo "JPEG OK ✓"
|
|
||||||
else
|
|
||||||
echo "返回: $(echo $result | xxd | head -1)"
|
|
||||||
fi
|
|
||||||
else
|
|
||||||
echo "✗ 启动失败"
|
|
||||||
tail -10 app.log
|
|
||||||
fi
|
|
||||||
@@ -0,0 +1,68 @@
|
|||||||
|
#!/usr/bin/env python3
|
||||||
|
"""系统时钟发布器。
|
||||||
|
|
||||||
|
向 /clock 持续发布系统时间,配合 Nav2 的 use_sim_time 配置,避免 AGV
|
||||||
|
节点、雷达节点和导航栈之间出现时间源不一致。
|
||||||
|
"""
|
||||||
|
import os
|
||||||
|
import sys
|
||||||
|
|
||||||
|
import rclpy
|
||||||
|
from rclpy.node import Node
|
||||||
|
from rosgraph_msgs.msg import Clock
|
||||||
|
|
||||||
|
|
||||||
|
LOCKFILE = "/tmp/clock_publisher.lock"
|
||||||
|
PUBLISH_INTERVAL_SECONDS = 0.01
|
||||||
|
LOG_INTERVAL_COUNT = 1000
|
||||||
|
|
||||||
|
|
||||||
|
def ensure_single_instance(lockfile: str) -> None:
|
||||||
|
if os.path.exists(lockfile):
|
||||||
|
with open(lockfile) as f:
|
||||||
|
old_pid = int(f.read().strip())
|
||||||
|
try:
|
||||||
|
os.kill(old_pid, 0)
|
||||||
|
print(f"Another clock_publisher running PID {old_pid}, exit.", file=sys.stderr)
|
||||||
|
sys.exit(1)
|
||||||
|
except (OSError, ProcessLookupError):
|
||||||
|
print(f"Stale lock removed (PID {old_pid} dead)", file=sys.stderr)
|
||||||
|
|
||||||
|
with open(lockfile, "w") as f:
|
||||||
|
f.write(str(os.getpid()))
|
||||||
|
|
||||||
|
|
||||||
|
class SystemClockPublisher(Node):
|
||||||
|
def __init__(self):
|
||||||
|
super().__init__("system_clock_publisher")
|
||||||
|
self.publisher = self.create_publisher(Clock, "/clock", 10)
|
||||||
|
self.timer = self.create_timer(PUBLISH_INTERVAL_SECONDS, self.publish_clock)
|
||||||
|
self.count = 0
|
||||||
|
self.get_logger().info(f"Clock publisher PID={os.getpid()}, publishing at 100Hz")
|
||||||
|
|
||||||
|
def publish_clock(self):
|
||||||
|
message = Clock()
|
||||||
|
message.clock = self.get_clock().now().to_msg()
|
||||||
|
self.publisher.publish(message)
|
||||||
|
self.count += 1
|
||||||
|
if self.count % LOG_INTERVAL_COUNT == 0:
|
||||||
|
self.get_logger().info(f"Published {self.count} clock messages")
|
||||||
|
|
||||||
|
|
||||||
|
def main():
|
||||||
|
ensure_single_instance(LOCKFILE)
|
||||||
|
rclpy.init(args=sys.argv[1:])
|
||||||
|
node = SystemClockPublisher()
|
||||||
|
try:
|
||||||
|
rclpy.spin(node)
|
||||||
|
except KeyboardInterrupt:
|
||||||
|
pass
|
||||||
|
finally:
|
||||||
|
node.destroy_node()
|
||||||
|
rclpy.shutdown()
|
||||||
|
if os.path.exists(LOCKFILE):
|
||||||
|
os.remove(LOCKFILE)
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
main()
|
||||||
@@ -0,0 +1,85 @@
|
|||||||
|
#!/usr/bin/env python3
|
||||||
|
"""激光雷达时间戳动态修正器。
|
||||||
|
|
||||||
|
订阅 /scan,将 LaserScan 的 header.stamp 替换为当前 ROS 时间后发布到
|
||||||
|
/scan_corrected,保证 AMCL/Costmap 收到的雷达时间戳和 TF 时间同步。
|
||||||
|
"""
|
||||||
|
import os
|
||||||
|
import sys
|
||||||
|
|
||||||
|
import rclpy
|
||||||
|
from rclpy.node import Node
|
||||||
|
from sensor_msgs.msg import LaserScan
|
||||||
|
|
||||||
|
|
||||||
|
LOCKFILE = "/tmp/scan_fixer.lock"
|
||||||
|
LOG_INTERVAL_COUNT = 200
|
||||||
|
|
||||||
|
|
||||||
|
def ensure_single_instance(lockfile: str) -> None:
|
||||||
|
if os.path.exists(lockfile):
|
||||||
|
with open(lockfile) as f:
|
||||||
|
old_pid = int(f.read().strip())
|
||||||
|
try:
|
||||||
|
os.kill(old_pid, 0)
|
||||||
|
print(f"Another fixer running PID {old_pid}, exit.", file=sys.stderr)
|
||||||
|
sys.exit(1)
|
||||||
|
except (OSError, ProcessLookupError):
|
||||||
|
print(f"Stale lock removed (PID {old_pid} dead)", file=sys.stderr)
|
||||||
|
|
||||||
|
with open(lockfile, "w") as f:
|
||||||
|
f.write(str(os.getpid()))
|
||||||
|
|
||||||
|
|
||||||
|
def copy_scan_with_current_time(source_scan: LaserScan, node: Node) -> LaserScan:
|
||||||
|
corrected_scan = LaserScan()
|
||||||
|
corrected_scan.header.frame_id = source_scan.header.frame_id
|
||||||
|
corrected_scan.header.stamp = node.get_clock().now().to_msg()
|
||||||
|
corrected_scan.angle_min = source_scan.angle_min
|
||||||
|
corrected_scan.angle_max = source_scan.angle_max
|
||||||
|
corrected_scan.angle_increment = source_scan.angle_increment
|
||||||
|
corrected_scan.time_increment = source_scan.time_increment
|
||||||
|
corrected_scan.scan_time = source_scan.scan_time
|
||||||
|
corrected_scan.range_min = source_scan.range_min
|
||||||
|
corrected_scan.range_max = source_scan.range_max
|
||||||
|
corrected_scan.ranges = source_scan.ranges
|
||||||
|
corrected_scan.intensities = source_scan.intensities
|
||||||
|
return corrected_scan
|
||||||
|
|
||||||
|
|
||||||
|
def main():
|
||||||
|
ensure_single_instance(LOCKFILE)
|
||||||
|
rclpy.init(args=sys.argv[1:])
|
||||||
|
node = Node("scan_timestamp_fixer")
|
||||||
|
publisher = node.create_publisher(LaserScan, "/scan_corrected", 10)
|
||||||
|
count = 0
|
||||||
|
first_timestamp = None
|
||||||
|
|
||||||
|
def on_scan(scan: LaserScan):
|
||||||
|
nonlocal count, first_timestamp
|
||||||
|
count += 1
|
||||||
|
if first_timestamp is None:
|
||||||
|
first_timestamp = scan.header.stamp.sec
|
||||||
|
node.get_logger().info(f"First /scan timestamp: {first_timestamp}")
|
||||||
|
|
||||||
|
publisher.publish(copy_scan_with_current_time(scan, node))
|
||||||
|
if count % LOG_INTERVAL_COUNT == 0:
|
||||||
|
node.get_logger().info(f"#{count} republished with current time")
|
||||||
|
|
||||||
|
node.create_subscription(LaserScan, "/scan", on_scan, 10)
|
||||||
|
node.get_logger().info(f"Fixer v6 PID={os.getpid()}, using current system time")
|
||||||
|
|
||||||
|
try:
|
||||||
|
while rclpy.ok():
|
||||||
|
rclpy.spin_once(node, timeout_sec=0.1)
|
||||||
|
except KeyboardInterrupt:
|
||||||
|
pass
|
||||||
|
finally:
|
||||||
|
node.destroy_node()
|
||||||
|
rclpy.shutdown()
|
||||||
|
if os.path.exists(LOCKFILE):
|
||||||
|
os.remove(LOCKFILE)
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
main()
|
||||||
@@ -0,0 +1,104 @@
|
|||||||
|
# AGV 智能巡检系统 — 脚本说明
|
||||||
|
|
||||||
|
## 目录结构
|
||||||
|
|
||||||
|
```
|
||||||
|
scripts/
|
||||||
|
├── prod-backend.sh ← 生产环境完整启动(ROS2 + Nav2 + Flask)
|
||||||
|
├── stop_all.sh ← 生产环境完整停止
|
||||||
|
├── dev-backend.sh ← 本地后端开发启动(Mock 硬件模式)
|
||||||
|
├── dev-frontend.sh ← 本地前端开发启动
|
||||||
|
└── stop.sh ← 停止本地开发服务
|
||||||
|
```
|
||||||
|
|
||||||
|
`scan_fixer/` 是生产启动链路的一部分:`clock_publisher.py` 发布 `/clock`,
|
||||||
|
`fix_scan_timestamp_v6.py` 将 `/scan` 重发为 `/scan_corrected`,供 Nav2/AMCL 使用。
|
||||||
|
|
||||||
|
## 使用场景
|
||||||
|
|
||||||
|
### 0. 初始化 Python 环境
|
||||||
|
|
||||||
|
项目使用 `uv` 统一管理 Python 虚拟环境,依赖声明在仓库根目录 `pyproject.toml`,锁定版本在 `uv.lock`。
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# 如系统尚未安装 uv,先安装 uv
|
||||||
|
curl -LsSf https://astral.sh/uv/install.sh | sh
|
||||||
|
|
||||||
|
# 在仓库根目录执行
|
||||||
|
cd ~/work/smart-inspection
|
||||||
|
uv sync
|
||||||
|
```
|
||||||
|
|
||||||
|
`uv sync` 会按 `.python-version` 创建 Python 3.10 虚拟环境到 `.venv`。ROS2 Humble 仍使用系统环境 `/opt/ros/humble`,不要把 ROS2 系统包写入 `pyproject.toml`。
|
||||||
|
|
||||||
|
系统依赖仍需通过系统包管理器安装:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
sudo apt install -y ffmpeg libzbar0
|
||||||
|
```
|
||||||
|
|
||||||
|
### 1. 首次开机 / 完整重启
|
||||||
|
```bash
|
||||||
|
# 在 AGV 上执行
|
||||||
|
cd ~/work/smart-inspection
|
||||||
|
./scripts/stop_all.sh # 先彻底清理
|
||||||
|
./scripts/prod-backend.sh # 完整启动
|
||||||
|
```
|
||||||
|
|
||||||
|
### 2. 修改代码后重新启动生产后端
|
||||||
|
```bash
|
||||||
|
# 部署文件到 AGV 后
|
||||||
|
ssh elephant@192.168.60.80 'cd ~/work/smart-inspection && ./scripts/prod-backend.sh'
|
||||||
|
```
|
||||||
|
|
||||||
|
### 3. 本地开发调试(不连硬件)
|
||||||
|
```bash
|
||||||
|
# 在本机执行,仅启动 Mock 后端
|
||||||
|
./scripts/dev-backend.sh
|
||||||
|
# 访问 http://127.0.0.1:5000
|
||||||
|
```
|
||||||
|
|
||||||
|
### 4. 本地前端开发
|
||||||
|
```bash
|
||||||
|
./scripts/dev-frontend.sh
|
||||||
|
```
|
||||||
|
|
||||||
|
## 环境变量
|
||||||
|
|
||||||
|
所有脚本支持通过环境变量覆盖默认路径:
|
||||||
|
|
||||||
|
| 变量 | 默认值 | 说明 |
|
||||||
|
|------|--------|------|
|
||||||
|
| `AGV_PROJECT_DIR` | `/home/elephant/work/smart-inspection` | 仓库根目录 |
|
||||||
|
| `AGV_APP_DIR` | `$AGV_PROJECT_DIR/agv_app` | Flask 应用目录 |
|
||||||
|
| `AGV_ROS2_DIR` | `/home/elephant/agv_pro_ros2` | ROS2 工作空间 |
|
||||||
|
| `ROS_SETUP` | `/opt/ros/humble/setup.bash` | ROS2 环境脚本 |
|
||||||
|
| `ROS_WORKSPACE_SETUP` | `$AGV_ROS2_DIR/install/setup.bash` | ROS2 工作空间环境脚本 |
|
||||||
|
| `SCAN_FIXER_DIR` | `$AGV_PROJECT_DIR/scan_fixer` | 时间戳修正工具目录 |
|
||||||
|
| `LOG_DIR` | `/tmp` | 日志目录 |
|
||||||
|
| `FASTRTPS_SHM_DIR` | `/dev/shm` | FastRTPS 共享内存目录 |
|
||||||
|
| `AGV_CONTROLLER_DEVICE` | `/dev/agvpro_controller` | AGV 控制器设备 |
|
||||||
|
| `ROS_DOMAIN_ID` | `1` | ROS2 通信域 ID |
|
||||||
|
|
||||||
|
## 日志位置(AGV 上)
|
||||||
|
|
||||||
|
| 组件 | 日志 |
|
||||||
|
|------|------|
|
||||||
|
| bringup (激光雷达) | `/tmp/ros2_bringup.log` |
|
||||||
|
| Nav2 导航 | `/tmp/ros2_nav2.log` |
|
||||||
|
| scan fixer | `/tmp/scan_fixer.log` |
|
||||||
|
| Flask | `/tmp/agv_flask.log` |
|
||||||
|
|
||||||
|
## 机械臂端
|
||||||
|
|
||||||
|
机械臂 (Pi) 的启动由 systemd 托管,在 Pi 上执行:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cd ~/work/smart-inspection
|
||||||
|
uv sync
|
||||||
|
sudo systemctl start arm_server # 启动
|
||||||
|
sudo systemctl status arm_server # 查看状态
|
||||||
|
sudo systemctl enable arm_server # 开机自启
|
||||||
|
```
|
||||||
|
|
||||||
|
配置见 `arm_server/arm_server.service`。
|
||||||
Executable
+32
@@ -0,0 +1,32 @@
|
|||||||
|
#!/bin/bash
|
||||||
|
# ============================================================
|
||||||
|
# dev-backend.sh - 本地后端开发启动(Mock 硬件模式)
|
||||||
|
# 用法: ./scripts/dev-backend.sh
|
||||||
|
# 说明: 启动 Flask 后端,使用 Mock 硬件实现,无需真实硬件
|
||||||
|
# ============================================================
|
||||||
|
set -e
|
||||||
|
|
||||||
|
SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)"
|
||||||
|
PROJECT_DIR="$(dirname "$SCRIPT_DIR")"
|
||||||
|
AGV_APP_DIR="$PROJECT_DIR/agv_app"
|
||||||
|
|
||||||
|
echo "=========================================="
|
||||||
|
echo " 本地开发模式 - Flask 后端 (Mock 硬件)"
|
||||||
|
echo "=========================================="
|
||||||
|
echo ""
|
||||||
|
echo " Mock 硬件模式已启用:"
|
||||||
|
echo " - AGV 控制器: Mock"
|
||||||
|
echo " - 机械臂: Mock"
|
||||||
|
echo " - 摄像头: Mock"
|
||||||
|
echo " - Nav2 导航: Mock"
|
||||||
|
echo ""
|
||||||
|
echo " 访问: http://127.0.0.1:5000"
|
||||||
|
echo " Ctrl+C 停止"
|
||||||
|
echo ""
|
||||||
|
|
||||||
|
# 设置环境变量启用 Mock 模式
|
||||||
|
export MOCK_HARDWARE=1
|
||||||
|
export FLASK_PORT=5000
|
||||||
|
|
||||||
|
cd "$AGV_APP_DIR"
|
||||||
|
exec uv run --locked python app.py
|
||||||
Executable
+26
@@ -0,0 +1,26 @@
|
|||||||
|
#!/bin/bash
|
||||||
|
# ============================================================
|
||||||
|
# dev-frontend.sh - 前端开发启动
|
||||||
|
# 用法: ./scripts/dev-frontend.sh
|
||||||
|
# 说明: 启动 Next.js 开发服务器,API 代理到后端
|
||||||
|
# ============================================================
|
||||||
|
set -e
|
||||||
|
|
||||||
|
SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)"
|
||||||
|
FRONTEND_DIR="$SCRIPT_DIR/../public-frontend"
|
||||||
|
|
||||||
|
echo "=========================================="
|
||||||
|
echo " 前端开发模式 - Next.js"
|
||||||
|
echo "=========================================="
|
||||||
|
echo ""
|
||||||
|
echo " 后端 URL: ${BACKEND_URL:-http://127.0.0.1:5000}"
|
||||||
|
echo " 访问: http://localhost:3000"
|
||||||
|
echo " Ctrl+C 停止"
|
||||||
|
echo ""
|
||||||
|
|
||||||
|
# 确保后端 URL 设置(默认本地)
|
||||||
|
export BACKEND_URL=${BACKEND_URL:-http://127.0.0.1:5000}
|
||||||
|
export NEXT_PUBLIC_BACKEND_URL=${BACKEND_URL}
|
||||||
|
|
||||||
|
cd "$FRONTEND_DIR"
|
||||||
|
exec npm run dev
|
||||||
Executable
+259
@@ -0,0 +1,259 @@
|
|||||||
|
#!/bin/bash
|
||||||
|
# ============================================================
|
||||||
|
# Robot AGV 全量启动脚本 v5.0
|
||||||
|
# 修复:
|
||||||
|
# - v5.0: 使用公共库重构,减少代码重复
|
||||||
|
# - v4.0: 彻底杀死 ros2 daemon 进程 + 启动前进程数量检查
|
||||||
|
# - v3.0: 彻底清理 FastRTPS 共享内存文件(永久修复 DDS 通信问题)
|
||||||
|
# - v2.7: 添加 ROS_DOMAIN_ID 环境变量传递
|
||||||
|
# - v2.6: 清理 scan_fixer lock 文件防残留
|
||||||
|
# ============================================================
|
||||||
|
set -euo pipefail
|
||||||
|
|
||||||
|
# 加载公共库
|
||||||
|
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
||||||
|
source "$SCRIPT_DIR/ros-common.sh"
|
||||||
|
|
||||||
|
# ============================================================================
|
||||||
|
# 主流程
|
||||||
|
# ============================================================================
|
||||||
|
|
||||||
|
section "Robot AGV 全量启动 v5.0"
|
||||||
|
|
||||||
|
# ============================================================================
|
||||||
|
# 1. 清理旧环境
|
||||||
|
# ============================================================================
|
||||||
|
step "1/8" "清理旧进程和共享内存"
|
||||||
|
|
||||||
|
kill_all_soft
|
||||||
|
kill_all_hard
|
||||||
|
stop_ros2_daemon
|
||||||
|
cleanup_fastrtps
|
||||||
|
|
||||||
|
# 验证进程已停止
|
||||||
|
echo " 验证进程停止..."
|
||||||
|
PROC_COUNT=$(count_residual_processes)
|
||||||
|
echo " 残留进程数: $PROC_COUNT"
|
||||||
|
if [ "$PROC_COUNT" -gt 0 ]; then
|
||||||
|
echo " [WARN] 仍有进程残留,再次强制终止..."
|
||||||
|
kill_all_hard
|
||||||
|
PROC_COUNT=$(count_residual_processes)
|
||||||
|
echo " 清理后残留: $PROC_COUNT"
|
||||||
|
fi
|
||||||
|
info "ok" "清理完成"
|
||||||
|
|
||||||
|
# ============================================================================
|
||||||
|
# 2. 启动 ros2 daemon
|
||||||
|
# ============================================================================
|
||||||
|
step "2/8" "启动 ros2 daemon"
|
||||||
|
rm -rf "$FASTRTPS_SHM_DIR"/fastrtps_* 2>/dev/null || true
|
||||||
|
start_ros2_daemon || true
|
||||||
|
|
||||||
|
# ============================================================================
|
||||||
|
# 3. 启动 bringup (含激光雷达)
|
||||||
|
# ============================================================================
|
||||||
|
step "3/8" "启动 AGV Bringup"
|
||||||
|
cd "$AGV_ROS2_DIR"
|
||||||
|
|
||||||
|
rm -rf "$FASTRTPS_SHM_DIR"/fastrtps_* 2>/dev/null || true
|
||||||
|
|
||||||
|
nohup bash -c '
|
||||||
|
source "$1" || exit 1
|
||||||
|
source "$2" || exit 1
|
||||||
|
export ROS_DOMAIN_ID="$3"
|
||||||
|
cd "$4" || exit 1
|
||||||
|
ros2 launch agv_pro_bringup agv_pro_bringup.launch.py port_name:="$5"
|
||||||
|
' _ "$ROS_SETUP" "$ROS_WORKSPACE_SETUP" "$ROS_DOMAIN_ID" "$AGV_ROS2_DIR" "$AGV_CONTROLLER_DEVICE" \
|
||||||
|
> "$BRINGUP_LOG" 2>&1 &
|
||||||
|
BRINGUP_PID=$!
|
||||||
|
echo " bringup PID: $BRINGUP_PID"
|
||||||
|
|
||||||
|
# 等待 /odom 话题
|
||||||
|
wait_for_topic /odom 40 || show_log_tail "$BRINGUP_LOG"
|
||||||
|
|
||||||
|
# ============================================================================
|
||||||
|
# 4. 启动系统时钟发布器
|
||||||
|
# ============================================================================
|
||||||
|
step "4/8" "启动系统时钟发布器 (clock_publisher)"
|
||||||
|
|
||||||
|
nohup bash -c "source \"$ROS_SETUP\" && \
|
||||||
|
ROS_DOMAIN_ID=$ROS_DOMAIN_ID python3 \"$SCAN_FIXER_DIR/clock_publisher.py\"" \
|
||||||
|
> "$CLOCK_LOG" 2>&1 &
|
||||||
|
CLOCK_PID=$!
|
||||||
|
echo " clock_publisher PID: $CLOCK_PID"
|
||||||
|
sleep 2
|
||||||
|
|
||||||
|
wait_for_topic /clock 10 || show_log_tail "$CLOCK_LOG"
|
||||||
|
|
||||||
|
# ============================================================================
|
||||||
|
# 5. 启动激光时间戳修正节点
|
||||||
|
# ============================================================================
|
||||||
|
step "5/8" "启动激光时间戳修正节点"
|
||||||
|
|
||||||
|
# 先等待 /scan 话题
|
||||||
|
if ! wait_for_topic /scan 20; then
|
||||||
|
echo " [WARN] /scan 未上线,检查 bringup 日志"
|
||||||
|
fi
|
||||||
|
|
||||||
|
nohup bash -c "source \"$ROS_SETUP\" && \
|
||||||
|
ROS_DOMAIN_ID=$ROS_DOMAIN_ID python3 \"$SCAN_FIXER_DIR/fix_scan_timestamp_v6.py\"" \
|
||||||
|
> "$SCAN_FIXER_LOG" 2>&1 &
|
||||||
|
FIXER_PID=$!
|
||||||
|
echo " fix_scan_timestamp PID: $FIXER_PID"
|
||||||
|
sleep 5
|
||||||
|
|
||||||
|
# 检查是否有多个 fixer 进程
|
||||||
|
FIXER_COUNT=$(count_matching_processes "fix_scan_timestamp")
|
||||||
|
if [ "$FIXER_COUNT" -gt 1 ]; then
|
||||||
|
echo " [WARN] 发现 $FIXER_COUNT 个 fixer 进程,重启..."
|
||||||
|
pkill -f "fix_scan_timestamp" 2>/dev/null || true
|
||||||
|
pkill -f "clock_publisher" 2>/dev/null || true
|
||||||
|
sleep 2
|
||||||
|
rm -f /tmp/scan_fixer.lock
|
||||||
|
nohup bash -c "source \"$ROS_SETUP\" && \
|
||||||
|
ROS_DOMAIN_ID=$ROS_DOMAIN_ID python3 \"$SCAN_FIXER_DIR/fix_scan_timestamp_v6.py\"" \
|
||||||
|
> "$SCAN_FIXER_LOG" 2>&1 &
|
||||||
|
FIXER_PID=$!
|
||||||
|
sleep 3
|
||||||
|
fi
|
||||||
|
|
||||||
|
wait_for_topic /scan_corrected 15 || show_log_tail "$SCAN_FIXER_LOG"
|
||||||
|
|
||||||
|
# ============================================================================
|
||||||
|
# 6. 启动 Nav2
|
||||||
|
# ============================================================================
|
||||||
|
step "6/8" "启动 Nav2 导航"
|
||||||
|
|
||||||
|
nohup bash -c "source \"$ROS_SETUP\" && source \"$ROS_WORKSPACE_SETUP\" && \
|
||||||
|
export ROS_DOMAIN_ID=$ROS_DOMAIN_ID && \
|
||||||
|
ros2 launch agv_pro_navigation2 navigation2_active.launch.py autostart:=True use_rviz:=False" \
|
||||||
|
> "$NAV2_LOG" 2>&1 &
|
||||||
|
NAV2_PID=$!
|
||||||
|
echo " Nav2 PID: $NAV2_PID"
|
||||||
|
sleep 12
|
||||||
|
|
||||||
|
echo " 等待 Nav2 节点就绪..."
|
||||||
|
wait_for_nodes 'lifecycle_manager_navigation|bt_navigator|controller_server' 3 45 || true
|
||||||
|
|
||||||
|
# ============================================================================
|
||||||
|
# 7. 设置精度参数
|
||||||
|
# ============================================================================
|
||||||
|
step "7/8" "设置导航精度参数 (xy_goal_tolerance=0.05m)"
|
||||||
|
|
||||||
|
for NODE in /controller_server /bt_navigator /planner_server; do
|
||||||
|
ros2_exec timeout 1 ros2 param set $NODE general_goal_checker.xy_goal_tolerance 0.05 2>/dev/null || true
|
||||||
|
ros2_exec timeout 1 ros2 param set $NODE general_goal_checker.yaw_goal_tolerance 0.05 2>/dev/null || true
|
||||||
|
done
|
||||||
|
ros2_exec timeout 1 ros2 param set /controller_server FollowPath.xy_goal_tolerance 0.05 2>/dev/null || true
|
||||||
|
ros2_exec timeout 1 ros2 param set /controller_server general_goal_checker.stateful True 2>/dev/null || true
|
||||||
|
ros2_exec timeout 1 ros2 param set /controller_server FollowPath.stateful True 2>/dev/null || true
|
||||||
|
|
||||||
|
info "ok" "精度参数已设置"
|
||||||
|
|
||||||
|
# ============================================================================
|
||||||
|
# 8. 启动 Flask API
|
||||||
|
# ============================================================================
|
||||||
|
step "8/8" "启动 Flask API"
|
||||||
|
|
||||||
|
cd "$AGV_APP_DIR"
|
||||||
|
UV_EXECUTABLE=$(resolve_uv_bin || true)
|
||||||
|
FLASK_PID=""
|
||||||
|
if [ -z "$UV_EXECUTABLE" ]; then
|
||||||
|
echo " [ERROR] 未找到 uv,可设置 UV_BIN=/path/to/uv"
|
||||||
|
else
|
||||||
|
nohup bash -c '
|
||||||
|
source "$1" || exit 1
|
||||||
|
source "$2" || exit 1
|
||||||
|
export ROS_DOMAIN_ID="$3"
|
||||||
|
cd "$4" || exit 1
|
||||||
|
exec "$5" run --locked python app.py
|
||||||
|
' _ "$ROS_SETUP" "$ROS_WORKSPACE_SETUP" "$ROS_DOMAIN_ID" "$AGV_APP_DIR" "$UV_EXECUTABLE" \
|
||||||
|
> "$FLASK_LOG" 2>&1 &
|
||||||
|
FLASK_PID=$!
|
||||||
|
echo " Flask PID: $FLASK_PID"
|
||||||
|
fi
|
||||||
|
sleep 4
|
||||||
|
|
||||||
|
# ============================================================================
|
||||||
|
# 9. 最终验证
|
||||||
|
# ============================================================================
|
||||||
|
section "系统全面验证"
|
||||||
|
|
||||||
|
# 验证话题数量
|
||||||
|
echo ""
|
||||||
|
echo "验证 ros2 topic list..."
|
||||||
|
TOPIC_COUNT=$(ros2_topic_count 5)
|
||||||
|
echo " 话题数量: $TOPIC_COUNT"
|
||||||
|
if [ "$TOPIC_COUNT" -gt 10 ]; then
|
||||||
|
info "ok" "ros2 daemon 正常 (${TOPIC_COUNT} 个话题)"
|
||||||
|
else
|
||||||
|
info "err" "ros2 topic list 异常 (${TOPIC_COUNT} 个话题,可能 DDS 有问题)"
|
||||||
|
echo " 手动执行: rm -rf \"$FASTRTPS_SHM_DIR\"/fastrtps_* && ros2 daemon stop && ros2 daemon start"
|
||||||
|
fi
|
||||||
|
|
||||||
|
# 验证关键话题
|
||||||
|
echo ""
|
||||||
|
echo "验证关键话题..."
|
||||||
|
for TOPIC in /odom /scan /cmd_vel /tf /clock /scan_corrected; do
|
||||||
|
if topic_exists "$TOPIC"; then
|
||||||
|
info "ok" "$TOPIC"
|
||||||
|
else
|
||||||
|
info "warn" "$TOPIC 未找到"
|
||||||
|
fi
|
||||||
|
done
|
||||||
|
|
||||||
|
# 验证进程数量
|
||||||
|
echo ""
|
||||||
|
echo "验证进程数量..."
|
||||||
|
BRINGUP_PROCS=$(count_matching_processes 'agv_pro_node|lslidar_driver_node')
|
||||||
|
echo " AGV 核心进程: $BRINGUP_PROCS (应为 2)"
|
||||||
|
if [ "$BRINGUP_PROCS" -eq 2 ]; then
|
||||||
|
info "ok" "进程数量正常(无重复)"
|
||||||
|
elif [ "$BRINGUP_PROCS" -gt 2 ]; then
|
||||||
|
info "warn" "发现 $BRINGUP_PROCS 个核心进程(可能有残留),建议重启"
|
||||||
|
else
|
||||||
|
info "warn" "进程数量异常"
|
||||||
|
fi
|
||||||
|
|
||||||
|
# FastRTPS 共享内存状态
|
||||||
|
echo ""
|
||||||
|
echo "FastRTPS 共享内存状态:"
|
||||||
|
FASTRTPS_NEW=$(count_fastrtps_files)
|
||||||
|
echo " 当前文件数: $FASTRTPS_NEW (正常运行时会有一些)"
|
||||||
|
|
||||||
|
# Flask API 状态
|
||||||
|
echo ""
|
||||||
|
echo "验证 Flask API..."
|
||||||
|
if pgrep -f "app.py" >/dev/null 2>&1; then
|
||||||
|
info "ok" "Flask 进程运行中"
|
||||||
|
else
|
||||||
|
info "err" "Flask 未运行"
|
||||||
|
fi
|
||||||
|
|
||||||
|
# ============================================================================
|
||||||
|
# 完成
|
||||||
|
# ============================================================================
|
||||||
|
section "[OK] 启动完成"
|
||||||
|
echo ""
|
||||||
|
echo " 进程状态:"
|
||||||
|
for proc_info in "bringup:$BRINGUP_PID" "Nav2:$NAV2_PID" "fixer:$FIXER_PID" "Flask:$FLASK_PID"; do
|
||||||
|
name="${proc_info%%:*}"
|
||||||
|
pid="${proc_info##*:}"
|
||||||
|
if [ -n "$pid" ] && ps -p "$pid" >/dev/null 2>&1; then
|
||||||
|
echo " $name : 运行中 (PID: $pid)"
|
||||||
|
else
|
||||||
|
echo " $name : 已退出"
|
||||||
|
fi
|
||||||
|
done
|
||||||
|
echo ""
|
||||||
|
echo " 日志文件:"
|
||||||
|
echo " bringup : $BRINGUP_LOG"
|
||||||
|
echo " Nav2 : $NAV2_LOG"
|
||||||
|
echo " fixer : $SCAN_FIXER_LOG"
|
||||||
|
echo " Flask : $FLASK_LOG"
|
||||||
|
echo ""
|
||||||
|
echo " 如果仍有问题,请依次执行:"
|
||||||
|
echo " 1. ./scripts/stop_all.sh"
|
||||||
|
echo " 2. rm -rf \"$FASTRTPS_SHM_DIR\"/fastrtps_*"
|
||||||
|
echo " 3. ./scripts/prod-backend.sh"
|
||||||
|
echo ""
|
||||||
Executable
+358
@@ -0,0 +1,358 @@
|
|||||||
|
#!/bin/bash
|
||||||
|
# ROS AGV 公共库
|
||||||
|
# 提供生产脚本共享的配置、清理与验证函数
|
||||||
|
|
||||||
|
set -euo pipefail
|
||||||
|
|
||||||
|
# ============================================================================
|
||||||
|
# 配置(可通过环境变量覆盖)
|
||||||
|
# ============================================================================
|
||||||
|
readonly AGV_PROJECT_DIR="${AGV_PROJECT_DIR:-$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)}"
|
||||||
|
readonly AGV_APP_DIR="${AGV_APP_DIR:-$AGV_PROJECT_DIR/agv_app}"
|
||||||
|
readonly AGV_ROS2_DIR="${AGV_ROS2_DIR:-$HOME/agv_pro_ros2}"
|
||||||
|
readonly ROS_SETUP="${ROS_SETUP:-/opt/ros/humble/setup.bash}"
|
||||||
|
readonly ROS_WORKSPACE_SETUP="${ROS_WORKSPACE_SETUP:-$AGV_ROS2_DIR/install/setup.bash}"
|
||||||
|
readonly SCAN_FIXER_DIR="${SCAN_FIXER_DIR:-$AGV_PROJECT_DIR/scan_fixer}"
|
||||||
|
readonly LOG_DIR="${LOG_DIR:-/tmp}"
|
||||||
|
readonly FASTRTPS_SHM_DIR="${FASTRTPS_SHM_DIR:-/dev/shm}"
|
||||||
|
readonly AGV_CONTROLLER_DEVICE="${AGV_CONTROLLER_DEVICE:-/dev/agvpro_controller}"
|
||||||
|
readonly UV_BIN="${UV_BIN:-}"
|
||||||
|
export ROS_DOMAIN_ID="${ROS_DOMAIN_ID:-1}"
|
||||||
|
|
||||||
|
# 日志文件
|
||||||
|
readonly BRINGUP_LOG="$LOG_DIR/ros2_bringup.log"
|
||||||
|
readonly NAV2_LOG="$LOG_DIR/ros2_nav2.log"
|
||||||
|
readonly CLOCK_LOG="$LOG_DIR/clock_publisher.log"
|
||||||
|
readonly SCAN_FIXER_LOG="$LOG_DIR/scan_fixer.log"
|
||||||
|
readonly FLASK_LOG="$LOG_DIR/agv_flask.log"
|
||||||
|
|
||||||
|
# ============================================================================
|
||||||
|
# 进程管理
|
||||||
|
# ============================================================================
|
||||||
|
|
||||||
|
# ROS 相关进程列表(用于清理)
|
||||||
|
readonly ROS_PROCESSES=(
|
||||||
|
"ros2 launch agv_pro_bringup"
|
||||||
|
"ros2 launch agv_pro_navigation2"
|
||||||
|
"agv_pro_node"
|
||||||
|
"lslidar_driver_node"
|
||||||
|
"component_container"
|
||||||
|
"robot_state_publisher"
|
||||||
|
"fix_scan_timestamp"
|
||||||
|
"clock_publisher"
|
||||||
|
"python.*app.py"
|
||||||
|
"uv run .*python app.py"
|
||||||
|
)
|
||||||
|
|
||||||
|
# 软杀所有进程
|
||||||
|
kill_all_soft() {
|
||||||
|
echo " 软杀进程中..."
|
||||||
|
for proc in "${ROS_PROCESSES[@]}"; do
|
||||||
|
pkill -f "$proc" 2>/dev/null || true
|
||||||
|
done
|
||||||
|
sleep 2
|
||||||
|
}
|
||||||
|
|
||||||
|
# 硬杀所有进程
|
||||||
|
kill_all_hard() {
|
||||||
|
echo " 强制终止中..."
|
||||||
|
for proc in "${ROS_PROCESSES[@]}"; do
|
||||||
|
pkill -9 -f "$proc" 2>/dev/null || true
|
||||||
|
done
|
||||||
|
sleep 1
|
||||||
|
}
|
||||||
|
|
||||||
|
# 统计匹配进程数
|
||||||
|
count_matching_processes() {
|
||||||
|
local pattern=$1
|
||||||
|
local current_pid=$$
|
||||||
|
local shell_pid=$BASHPID
|
||||||
|
local parent_pid=$PPID
|
||||||
|
local count=0
|
||||||
|
local pid
|
||||||
|
local args
|
||||||
|
|
||||||
|
while read -r pid args; do
|
||||||
|
if [ -z "${pid:-}" ]; then
|
||||||
|
continue
|
||||||
|
fi
|
||||||
|
if [ "$pid" = "$current_pid" ] || [ "$pid" = "$shell_pid" ] || [ "$pid" = "$parent_pid" ]; then
|
||||||
|
continue
|
||||||
|
fi
|
||||||
|
if [[ "${args:-}" =~ $pattern ]]; then
|
||||||
|
count=$((count + 1))
|
||||||
|
fi
|
||||||
|
done < <(ps -eo pid=,args=)
|
||||||
|
|
||||||
|
echo "$count"
|
||||||
|
}
|
||||||
|
|
||||||
|
# 统计残留进程数
|
||||||
|
count_residual_processes() {
|
||||||
|
count_matching_processes "agv_pro_node|lslidar_driver_node|component_container|fix_scan_timestamp|clock_publisher|app.py|ros2-daemon"
|
||||||
|
}
|
||||||
|
|
||||||
|
# ============================================================================
|
||||||
|
# FastRTPS 清理
|
||||||
|
# ============================================================================
|
||||||
|
|
||||||
|
# 清理 FastRTPS 共享内存
|
||||||
|
cleanup_fastrtps() {
|
||||||
|
local count
|
||||||
|
count=$(count_fastrtps_files)
|
||||||
|
|
||||||
|
if [ "$count" -gt 0 ]; then
|
||||||
|
rm -rf "$FASTRTPS_SHM_DIR"/fastrtps_*
|
||||||
|
echo " 已清理 $count 个 FastRTPS 文件"
|
||||||
|
else
|
||||||
|
echo " 无 FastRTPS 文件残留"
|
||||||
|
fi
|
||||||
|
|
||||||
|
# 清理锁文件
|
||||||
|
rm -f /tmp/scan_fixer.lock /tmp/clock_publisher.lock
|
||||||
|
}
|
||||||
|
|
||||||
|
# 统计 FastRTPS 文件数
|
||||||
|
count_fastrtps_files() (
|
||||||
|
shopt -s nullglob
|
||||||
|
local files=("$FASTRTPS_SHM_DIR"/fastrtps_*)
|
||||||
|
echo "${#files[@]}"
|
||||||
|
)
|
||||||
|
|
||||||
|
# 载入 ROS2 环境后执行命令
|
||||||
|
ros2_exec() {
|
||||||
|
bash -c '
|
||||||
|
source "$1" || exit 1
|
||||||
|
if [ -f "$2" ]; then
|
||||||
|
source "$2" || exit 1
|
||||||
|
fi
|
||||||
|
export ROS_DOMAIN_ID="$3"
|
||||||
|
shift 3
|
||||||
|
"$@"
|
||||||
|
' _ "$ROS_SETUP" "$ROS_WORKSPACE_SETUP" "$ROS_DOMAIN_ID" "$@"
|
||||||
|
}
|
||||||
|
|
||||||
|
resolve_uv_bin() {
|
||||||
|
if [ -n "$UV_BIN" ]; then
|
||||||
|
if [ -x "$UV_BIN" ]; then
|
||||||
|
echo "$UV_BIN"
|
||||||
|
return 0
|
||||||
|
fi
|
||||||
|
return 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
local candidate
|
||||||
|
candidate=$(command -v uv 2>/dev/null || true)
|
||||||
|
if [ -n "$candidate" ]; then
|
||||||
|
echo "$candidate"
|
||||||
|
return 0
|
||||||
|
fi
|
||||||
|
|
||||||
|
for candidate in "$HOME/.local/bin/uv" "$HOME/.cargo/bin/uv"; do
|
||||||
|
if [ -x "$candidate" ]; then
|
||||||
|
echo "$candidate"
|
||||||
|
return 0
|
||||||
|
fi
|
||||||
|
done
|
||||||
|
|
||||||
|
return 1
|
||||||
|
}
|
||||||
|
|
||||||
|
# ============================================================================
|
||||||
|
# ROS2 环境操作
|
||||||
|
# ============================================================================
|
||||||
|
|
||||||
|
ros2_topic_list() {
|
||||||
|
ros2_exec ros2 topic list 2>/dev/null || true
|
||||||
|
}
|
||||||
|
|
||||||
|
ros2_topic_count() {
|
||||||
|
local topics
|
||||||
|
topics=$(ros2_exec timeout "${1:-5}" ros2 topic list 2>/dev/null || true)
|
||||||
|
if [ -z "$topics" ]; then
|
||||||
|
echo 0
|
||||||
|
else
|
||||||
|
printf '%s\n' "$topics" | sed '/^$/d' | wc -l
|
||||||
|
fi
|
||||||
|
}
|
||||||
|
|
||||||
|
topic_exists() {
|
||||||
|
ros2_topic_list | grep -Fxq "$1"
|
||||||
|
}
|
||||||
|
|
||||||
|
# 启动 ROS2 daemon
|
||||||
|
start_ros2_daemon() {
|
||||||
|
echo " 启动 ros2 daemon..."
|
||||||
|
rm -rf "$FASTRTPS_SHM_DIR"/fastrtps_* 2>/dev/null || true
|
||||||
|
|
||||||
|
nohup bash -c '
|
||||||
|
source "$1" || exit 1
|
||||||
|
export ROS_DOMAIN_ID="$2"
|
||||||
|
ros2 daemon start
|
||||||
|
' _ "$ROS_SETUP" "$ROS_DOMAIN_ID" >/dev/null 2>&1 &
|
||||||
|
sleep 4
|
||||||
|
|
||||||
|
# 等待 daemon 就绪
|
||||||
|
for _ in $(seq 1 5); do
|
||||||
|
if ros2_exec timeout 3 ros2 topic list &>/dev/null; then
|
||||||
|
echo " [OK] ros2 daemon 已就绪"
|
||||||
|
return 0
|
||||||
|
fi
|
||||||
|
sleep 2
|
||||||
|
done
|
||||||
|
echo " [WARN] ros2 daemon 可能有问题"
|
||||||
|
return 1
|
||||||
|
}
|
||||||
|
|
||||||
|
# 停止 ROS2 daemon
|
||||||
|
stop_ros2_daemon() {
|
||||||
|
echo " 重置 ros2 daemon..."
|
||||||
|
pkill -f "ros2-daemon" 2>/dev/null || true
|
||||||
|
pkill -9 -f "ros2-daemon" 2>/dev/null || true
|
||||||
|
sleep 2
|
||||||
|
|
||||||
|
bash -c '
|
||||||
|
source "$1" || exit 0
|
||||||
|
export ROS_DOMAIN_ID="$2"
|
||||||
|
ros2 daemon stop
|
||||||
|
' _ "$ROS_SETUP" "$ROS_DOMAIN_ID" 2>/dev/null || true
|
||||||
|
echo " [OK] ros2 daemon 已重置"
|
||||||
|
}
|
||||||
|
|
||||||
|
# ============================================================================
|
||||||
|
# 等待/验证函数
|
||||||
|
# ============================================================================
|
||||||
|
|
||||||
|
# 等待话题出现
|
||||||
|
# 用法: wait_for_topic <话题名> <最大等待秒数>
|
||||||
|
wait_for_topic() {
|
||||||
|
local topic=$1
|
||||||
|
local max_wait=${2:-30}
|
||||||
|
local elapsed=0
|
||||||
|
|
||||||
|
while [ "$elapsed" -lt "$max_wait" ]; do
|
||||||
|
if topic_exists "$topic"; then
|
||||||
|
echo " [OK] $topic 已上线"
|
||||||
|
return 0
|
||||||
|
fi
|
||||||
|
sleep 2
|
||||||
|
elapsed=$((elapsed + 2))
|
||||||
|
done
|
||||||
|
echo " [WARN] $topic 未在 $max_wait 秒内上线"
|
||||||
|
return 1
|
||||||
|
}
|
||||||
|
|
||||||
|
# 等待节点出现(匹配数量)
|
||||||
|
# 用法: wait_for_nodes <节点模式> <期望数量> <最大等待秒数>
|
||||||
|
wait_for_nodes() {
|
||||||
|
local pattern=$1
|
||||||
|
local expected=$2
|
||||||
|
local max_wait=${3:-30}
|
||||||
|
local elapsed=0
|
||||||
|
local count=0
|
||||||
|
|
||||||
|
while [ "$elapsed" -lt "$max_wait" ]; do
|
||||||
|
local nodes
|
||||||
|
nodes=$(ros2_exec timeout 8 ros2 node list --no-daemon --spin-time 3 2>/dev/null || true)
|
||||||
|
count=$(printf '%s\n' "$nodes" | grep -cE "$pattern" || true)
|
||||||
|
if [ "$count" -ge "$expected" ]; then
|
||||||
|
echo " [OK] 已检测到 $count 个节点"
|
||||||
|
return 0
|
||||||
|
fi
|
||||||
|
sleep 2
|
||||||
|
elapsed=$((elapsed + 2))
|
||||||
|
done
|
||||||
|
echo " [WARN] 仅检测到 $count 个节点(期望 $expected 个)"
|
||||||
|
return 1
|
||||||
|
}
|
||||||
|
|
||||||
|
# ============================================================================
|
||||||
|
# 日志/输出辅助
|
||||||
|
# ============================================================================
|
||||||
|
|
||||||
|
# 打印分节标题
|
||||||
|
section() {
|
||||||
|
echo ""
|
||||||
|
echo "=========================================="
|
||||||
|
echo " $1"
|
||||||
|
echo "=========================================="
|
||||||
|
}
|
||||||
|
|
||||||
|
# 打印步骤
|
||||||
|
step() {
|
||||||
|
echo ""
|
||||||
|
echo "[$1] $2"
|
||||||
|
}
|
||||||
|
|
||||||
|
# 打印带状态的信息
|
||||||
|
info() {
|
||||||
|
local status=$1
|
||||||
|
local msg=$2
|
||||||
|
if [ "$status" = "ok" ]; then
|
||||||
|
echo " [OK] $msg"
|
||||||
|
elif [ "$status" = "warn" ]; then
|
||||||
|
echo " [WARN] $msg"
|
||||||
|
elif [ "$status" = "err" ]; then
|
||||||
|
echo " [ERROR] $msg"
|
||||||
|
else
|
||||||
|
echo " $msg"
|
||||||
|
fi
|
||||||
|
}
|
||||||
|
|
||||||
|
# 显示日志尾部
|
||||||
|
show_log_tail() {
|
||||||
|
local log_file=$1
|
||||||
|
local lines=${2:-5}
|
||||||
|
if [ -f "$log_file" ]; then
|
||||||
|
echo " --- 日志尾部 ($log_file) ---"
|
||||||
|
tail -"$lines" "$log_file" 2>/dev/null | sed 's/^/ /' || true
|
||||||
|
fi
|
||||||
|
}
|
||||||
|
|
||||||
|
# ============================================================================
|
||||||
|
# 完整清理流程
|
||||||
|
# ============================================================================
|
||||||
|
|
||||||
|
# 执行完整清理(供 stop_all.sh 使用)
|
||||||
|
full_cleanup() {
|
||||||
|
section "Robot AGV 全量停止"
|
||||||
|
|
||||||
|
step "1/5" "软杀所有相关进程"
|
||||||
|
kill_all_soft
|
||||||
|
|
||||||
|
step "2/5" "强制终止残留进程"
|
||||||
|
kill_all_hard
|
||||||
|
|
||||||
|
step "3/5" "重置 ros2 daemon"
|
||||||
|
stop_ros2_daemon
|
||||||
|
|
||||||
|
step "4/5" "清理 FastRTPS 共享内存"
|
||||||
|
cleanup_fastrtps
|
||||||
|
|
||||||
|
step "5/5" "验证清理结果"
|
||||||
|
local proc_count=$(count_residual_processes)
|
||||||
|
local fastrtps_left=$(count_fastrtps_files)
|
||||||
|
|
||||||
|
echo " 残留进程数: $proc_count"
|
||||||
|
echo " FastRTPS 文件数: $fastrtps_left"
|
||||||
|
|
||||||
|
if [ "$proc_count" -eq 0 ] && [ "$fastrtps_left" -eq 0 ]; then
|
||||||
|
section "[OK] 停止完成 - 系统已完全清理"
|
||||||
|
else
|
||||||
|
section "[WARN] 停止完成 - 部分残留可能需要手动清理"
|
||||||
|
echo ""
|
||||||
|
echo " 手动清理命令(如需要):"
|
||||||
|
echo " pkill -9 -f 'agv_pro_node|lslidar|component_container'"
|
||||||
|
echo " pkill -9 -f 'fix_scan_timestamp|app.py'"
|
||||||
|
echo " pkill -9 -f 'ros2-daemon'"
|
||||||
|
echo " rm -rf \"$FASTRTPS_SHM_DIR\"/fastrtps_*"
|
||||||
|
fi
|
||||||
|
echo ""
|
||||||
|
echo " 现在可以安全运行 ./scripts/prod-backend.sh"
|
||||||
|
echo ""
|
||||||
|
}
|
||||||
|
|
||||||
|
# ============================================================================
|
||||||
|
# 初始化(确保目录存在)
|
||||||
|
# ============================================================================
|
||||||
|
mkdir -p "$LOG_DIR"
|
||||||
Executable
+34
@@ -0,0 +1,34 @@
|
|||||||
|
#!/bin/bash
|
||||||
|
# ============================================================
|
||||||
|
# stop.sh - 停止开发服务
|
||||||
|
# 用法: ./scripts/stop.sh
|
||||||
|
# 说明: 停止 Flask 和 Next.js 开发服务器
|
||||||
|
# ============================================================
|
||||||
|
set -e
|
||||||
|
|
||||||
|
echo "=========================================="
|
||||||
|
echo " 停止开发服务"
|
||||||
|
echo "=========================================="
|
||||||
|
echo ""
|
||||||
|
|
||||||
|
# 停止 Flask
|
||||||
|
if pgrep -f "python.*app.py" > /dev/null 2>&1; then
|
||||||
|
echo "停止 Flask..."
|
||||||
|
pkill -f "python.*app.py" || true
|
||||||
|
pkill -f "uv run .*python app.py" || true
|
||||||
|
echo " ✓ Flask 已停止"
|
||||||
|
else
|
||||||
|
echo " - Flask 未运行"
|
||||||
|
fi
|
||||||
|
|
||||||
|
# 停止 Next.js
|
||||||
|
if pgrep -f "next dev" > /dev/null 2>&1; then
|
||||||
|
echo "停止 Next.js..."
|
||||||
|
pkill -f "next dev" || true
|
||||||
|
echo " ✓ Next.js 已停止"
|
||||||
|
else
|
||||||
|
echo " - Next.js 未运行"
|
||||||
|
fi
|
||||||
|
|
||||||
|
echo ""
|
||||||
|
echo "完成"
|
||||||
Executable
+16
@@ -0,0 +1,16 @@
|
|||||||
|
#!/bin/bash
|
||||||
|
# ============================================================
|
||||||
|
# stop_all.sh - 关闭 AGV 拍摄系统所有相关进程
|
||||||
|
# 版本: v3.0
|
||||||
|
# 修复:
|
||||||
|
# - v3.0: 使用公共库重构,减少代码重复
|
||||||
|
# - v2.0: 添加 FastRTPS 清理 + ros2 daemon 重置
|
||||||
|
# ============================================================
|
||||||
|
set -euo pipefail
|
||||||
|
|
||||||
|
# 加载公共库
|
||||||
|
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
||||||
|
source "$SCRIPT_DIR/ros-common.sh"
|
||||||
|
|
||||||
|
# 执行完整清理
|
||||||
|
full_cleanup
|
||||||
@@ -1,23 +0,0 @@
|
|||||||
{
|
|
||||||
"agv": {
|
|
||||||
"ip": "192.168.60.177",
|
|
||||||
"ssh_user": "elephant",
|
|
||||||
"ssh_password": "Elephant",
|
|
||||||
"map_file": "map.yaml",
|
|
||||||
"map_dir": "/home/elephant"
|
|
||||||
},
|
|
||||||
"arm": {
|
|
||||||
"ip": "192.168.60.88",
|
|
||||||
"ssh_user": "pi",
|
|
||||||
"ssh_password": "elephant",
|
|
||||||
"socket_port": 5001,
|
|
||||||
"roboflow_host": "127.0.0.1",
|
|
||||||
"roboflow_port": 5001
|
|
||||||
},
|
|
||||||
"app": {
|
|
||||||
"upload_url": "https://ts.timeddd.com/prod-api/file/uploadImage",
|
|
||||||
"agv_control_port": 5000,
|
|
||||||
"arm_server_port": 5002,
|
|
||||||
"secret_key": "agv630_secret_key_2024"
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,24 +0,0 @@
|
|||||||
#!/bin/bash
|
|
||||||
# AGV 服务启动脚本
|
|
||||||
cd /home/elephant/work/agv_app
|
|
||||||
|
|
||||||
# 确保没有旧进程
|
|
||||||
pkill -f "python.*app.py" 2>/dev/null
|
|
||||||
sleep 1
|
|
||||||
|
|
||||||
# 启动服务
|
|
||||||
nohup python3 app.py > app.log 2>&1 &
|
|
||||||
PID=$!
|
|
||||||
echo "Started PID=$PID"
|
|
||||||
|
|
||||||
sleep 3
|
|
||||||
|
|
||||||
# 验证
|
|
||||||
if ss -tlnp | grep 5000; then
|
|
||||||
echo "✓ 端口 5000 监听正常"
|
|
||||||
curl -s http://127.0.0.1:5000/api/mission/state
|
|
||||||
echo ""
|
|
||||||
else
|
|
||||||
echo "✗ 端口 5000 未监听,检查日志:"
|
|
||||||
cat app.log
|
|
||||||
fi
|
|
||||||
@@ -0,0 +1,320 @@
|
|||||||
|
version = 1
|
||||||
|
revision = 3
|
||||||
|
requires-python = "==3.10.*"
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "certifi"
|
||||||
|
version = "2026.6.17"
|
||||||
|
source = { registry = "https://pypi.org/simple" }
|
||||||
|
sdist = { url = "https://files.pythonhosted.org/packages/c9/c7/424b75da314c1045981bd9777432fad05a9e0c69daa4ed7e308bbaffe405/certifi-2026.6.17.tar.gz", hash = "sha256:024c88eeec92ca068db80f02b8b07c9cef7b9fe261d1d535abfd5abd6f6af432", size = 134594, upload-time = "2026-06-17T10:31:07.894Z" }
|
||||||
|
wheels = [
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/ef/2f/c5464532e965badff2f4c4c1a3a83f5697f0d7c407ed0cda44aaa99bb451/certifi-2026.6.17-py3-none-any.whl", hash = "sha256:2227dcbaafe0d2f59279d1762ddddc37783ed4354594f194ffc31d20f41fc3db", size = 133289, upload-time = "2026-06-17T10:31:06.348Z" },
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "charset-normalizer"
|
||||||
|
version = "3.4.7"
|
||||||
|
source = { registry = "https://pypi.org/simple" }
|
||||||
|
sdist = { url = "https://files.pythonhosted.org/packages/e7/a1/67fe25fac3c7642725500a3f6cfe5821ad557c3abb11c9d20d12c7008d3e/charset_normalizer-3.4.7.tar.gz", hash = "sha256:ae89db9e5f98a11a4bf50407d4363e7b09b31e55bc117b4f7d80aab97ba009e5", size = 144271, upload-time = "2026-04-02T09:28:39.342Z" }
|
||||||
|
wheels = [
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/26/08/0f303cb0b529e456bb116f2d50565a482694fbb94340bf56d44677e7ed03/charset_normalizer-3.4.7-cp310-cp310-macosx_10_9_universal2.whl", hash = "sha256:cdd68a1fb318e290a2077696b7eb7a21a49163c455979c639bf5a5dcdc46617d", size = 315182, upload-time = "2026-04-02T09:25:40.673Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/24/47/b192933e94b546f1b1fe4df9cc1f84fcdbf2359f8d1081d46dd029b50207/charset_normalizer-3.4.7-cp310-cp310-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:e17b8d5d6a8c47c85e68ca8379def1303fd360c3e22093a807cd34a71cd082b8", size = 209329, upload-time = "2026-04-02T09:25:42.354Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/c2/b4/01fa81c5ca6141024d89a8fc15968002b71da7f825dd14113207113fabbd/charset_normalizer-3.4.7-cp310-cp310-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:511ef87c8aec0783e08ac18565a16d435372bc1ac25a91e6ac7f5ef2b0bff790", size = 231230, upload-time = "2026-04-02T09:25:44.281Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/20/f7/7b991776844dfa058017e600e6e55ff01984a063290ca5622c0b63162f68/charset_normalizer-3.4.7-cp310-cp310-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:007d05ec7321d12a40227aae9e2bc6dca73f3cb21058999a1df9e193555a9dcc", size = 225890, upload-time = "2026-04-02T09:25:45.475Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/20/e7/bed0024a0f4ab0c8a9c64d4445f39b30c99bd1acd228291959e3de664247/charset_normalizer-3.4.7-cp310-cp310-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:cf29836da5119f3c8a8a70667b0ef5fdca3bb12f80fd06487cfa575b3909b393", size = 216930, upload-time = "2026-04-02T09:25:46.58Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/e2/ab/b18f0ab31cdd7b3ddb8bb76c4a414aeb8160c9810fdf1bc62f269a539d87/charset_normalizer-3.4.7-cp310-cp310-manylinux_2_31_armv7l.whl", hash = "sha256:12d8baf840cc7889b37c7c770f478adea7adce3dcb3944d02ec87508e2dcf153", size = 202109, upload-time = "2026-04-02T09:25:48.031Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/82/e5/7e9440768a06dfb3075936490cb82dbf0ee20a133bf0dd8551fa096914ec/charset_normalizer-3.4.7-cp310-cp310-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:d560742f3c0d62afaccf9f41fe485ed69bd7661a241f86a3ef0f0fb8b1a397af", size = 214684, upload-time = "2026-04-02T09:25:49.245Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/71/94/8c61d8da9f062fdf457c80acfa25060ec22bf1d34bbeaca4350f13bcfd07/charset_normalizer-3.4.7-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:b14b2d9dac08e28bb8046a1a0434b1750eb221c8f5b87a68f4fa11a6f97b5e34", size = 212785, upload-time = "2026-04-02T09:25:50.671Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/66/cd/6e9889c648e72c0ab2e5967528bb83508f354d706637bc7097190c874e13/charset_normalizer-3.4.7-cp310-cp310-musllinux_1_2_armv7l.whl", hash = "sha256:bc17a677b21b3502a21f66a8cc64f5bfad4df8a0b8434d661666f8ce90ac3af1", size = 203055, upload-time = "2026-04-02T09:25:51.802Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/92/2e/7a951d6a08aefb7eb8e1b54cdfb580b1365afdd9dd484dc4bee9e5d8f258/charset_normalizer-3.4.7-cp310-cp310-musllinux_1_2_ppc64le.whl", hash = "sha256:750e02e074872a3fad7f233b47734166440af3cdea0add3e95163110816d6752", size = 232502, upload-time = "2026-04-02T09:25:53.388Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/58/d5/abcf2d83bf8e0a1286df55cd0dc1d49af0da4282aa77e986df343e7de124/charset_normalizer-3.4.7-cp310-cp310-musllinux_1_2_riscv64.whl", hash = "sha256:4e5163c14bffd570ef2affbfdd77bba66383890797df43dc8b4cc7d6f500bf53", size = 214295, upload-time = "2026-04-02T09:25:54.765Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/47/3a/7d4cd7ed54be99973a0dc176032cba5cb1f258082c31fa6df35cff46acfc/charset_normalizer-3.4.7-cp310-cp310-musllinux_1_2_s390x.whl", hash = "sha256:6ed74185b2db44f41ef35fd1617c5888e59792da9bbc9190d6c7300617182616", size = 227145, upload-time = "2026-04-02T09:25:55.904Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/1d/98/3a45bf8247889cf28262ebd3d0872edff11565b2a1e3064ccb132db3fbb0/charset_normalizer-3.4.7-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:94e1885b270625a9a828c9793b4d52a64445299baa1fea5a173bf1d3dd9a1a5a", size = 218884, upload-time = "2026-04-02T09:25:57.074Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/ad/80/2e8b7f8915ed5c9ef13aa828d82738e33888c485b65ebf744d615040c7ea/charset_normalizer-3.4.7-cp310-cp310-win32.whl", hash = "sha256:6785f414ae0f3c733c437e0f3929197934f526d19dfaa75e18fdb4f94c6fb374", size = 148343, upload-time = "2026-04-02T09:25:58.199Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/35/1b/3b8c8c77184af465ee9ad88b5aea46ea6b2e1f7b9dc9502891e37af21e30/charset_normalizer-3.4.7-cp310-cp310-win_amd64.whl", hash = "sha256:6696b7688f54f5af4462118f0bfa7c1621eeb87154f77fa04b9295ce7a8f2943", size = 159174, upload-time = "2026-04-02T09:25:59.322Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/be/c1/feb40dca40dbb21e0a908801782d9288c64fc8d8e562c2098e9994c8c21b/charset_normalizer-3.4.7-cp310-cp310-win_arm64.whl", hash = "sha256:66671f93accb62ed07da56613636f3641f1a12c13046ce91ffc923721f23c008", size = 147805, upload-time = "2026-04-02T09:26:00.756Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/db/8f/61959034484a4a7c527811f4721e75d02d653a35afb0b6054474d8185d4c/charset_normalizer-3.4.7-py3-none-any.whl", hash = "sha256:3dce51d0f5e7951f8bb4900c257dad282f49190fdbebecd4ba99bcc41fef404d", size = 61958, upload-time = "2026-04-02T09:28:37.794Z" },
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "click"
|
||||||
|
version = "8.4.1"
|
||||||
|
source = { registry = "https://pypi.org/simple" }
|
||||||
|
dependencies = [
|
||||||
|
{ name = "colorama", marker = "sys_platform == 'win32'" },
|
||||||
|
]
|
||||||
|
sdist = { url = "https://files.pythonhosted.org/packages/9b/98/518d8e5081007684232226f475082b30087d0f585e8457db087298259f49/click-8.4.1.tar.gz", hash = "sha256:918b5633eddf6b41c32d4f454bf0de810065c74e3f7dbf8ee5452f8be88d3e96", size = 353007, upload-time = "2026-05-22T04:08:37.769Z" }
|
||||||
|
wheels = [
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/c7/0d/67e5b4109ea4a837e80daa87c2c696711955e40449a97e8926672534def2/click-8.4.1-py3-none-any.whl", hash = "sha256:482be17c6991b8c19c5429a1e995d9b0efdbb63172824c41f99965dc0ade8ec2", size = 116639, upload-time = "2026-05-22T04:08:35.26Z" },
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "colorama"
|
||||||
|
version = "0.4.6"
|
||||||
|
source = { registry = "https://pypi.org/simple" }
|
||||||
|
sdist = { url = "https://files.pythonhosted.org/packages/d8/53/6f443c9a4a8358a93a6792e2acffb9d9d5cb0a5cfd8802644b7b1c9a02e4/colorama-0.4.6.tar.gz", hash = "sha256:08695f5cb7ed6e0531a20572697297273c47b8cae5a63ffc6d6ed5c201be6e44", size = 27697, upload-time = "2022-10-25T02:36:22.414Z" }
|
||||||
|
wheels = [
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/d1/d6/3965ed04c63042e047cb6a3e6ed1a63a35087b6a609aa3a15ed8ac56c221/colorama-0.4.6-py2.py3-none-any.whl", hash = "sha256:4f1d9991f5acc0ca119f9d443620b77f9d6b33703e51011c16baf57afb285fc6", size = 25335, upload-time = "2022-10-25T02:36:20.889Z" },
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "flask"
|
||||||
|
version = "2.2.5"
|
||||||
|
source = { registry = "https://pypi.org/simple" }
|
||||||
|
dependencies = [
|
||||||
|
{ name = "click" },
|
||||||
|
{ name = "itsdangerous" },
|
||||||
|
{ name = "jinja2" },
|
||||||
|
{ name = "werkzeug" },
|
||||||
|
]
|
||||||
|
sdist = { url = "https://files.pythonhosted.org/packages/5f/76/a4d2c4436dda4b0a12c71e075c508ea7988a1066b06a575f6afe4fecc023/Flask-2.2.5.tar.gz", hash = "sha256:edee9b0a7ff26621bd5a8c10ff484ae28737a2410d99b0bb9a6850c7fb977aa0", size = 697814, upload-time = "2023-05-02T14:42:36.742Z" }
|
||||||
|
wheels = [
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/9f/1a/8b6d48162861009d1e017a9740431c78d860809773b66cac220a11aa3310/Flask-2.2.5-py3-none-any.whl", hash = "sha256:58107ed83443e86067e41eff4631b058178191a355886f8e479e347fa1285fdf", size = 101817, upload-time = "2023-05-02T14:42:34.858Z" },
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "flask-cors"
|
||||||
|
version = "6.0.5"
|
||||||
|
source = { registry = "https://pypi.org/simple" }
|
||||||
|
dependencies = [
|
||||||
|
{ name = "flask" },
|
||||||
|
{ name = "typing-extensions" },
|
||||||
|
{ name = "werkzeug" },
|
||||||
|
]
|
||||||
|
sdist = { url = "https://files.pythonhosted.org/packages/47/03/4e464a50860f9adf08b5c1d3479cb8ea1f12af2aa69535c7042c6e628135/flask_cors-6.0.5.tar.gz", hash = "sha256:30c5031552cd59f620ac0c8211dac45b345d3b2df310e7721879e4f46ef9c601", size = 101386, upload-time = "2026-06-08T20:20:17.765Z" }
|
||||||
|
wheels = [
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/49/55/5bb1a2d918e9f02f131e47a59032bae70e48050e986e941511fd737a935c/flask_cors-6.0.5-py3-none-any.whl", hash = "sha256:68fcf75693e961f3af26683b23c4b9a8fb6b64de17d20d0c37b95e8de7ab2ed8", size = 16692, upload-time = "2026-06-08T20:20:16.247Z" },
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "idna"
|
||||||
|
version = "3.18"
|
||||||
|
source = { registry = "https://pypi.org/simple" }
|
||||||
|
sdist = { url = "https://files.pythonhosted.org/packages/cd/63/9496c57188a2ee585e0f1db071d75089a11e98aa86eb99d9d7618fc1edce/idna-3.18.tar.gz", hash = "sha256:ffb385a7e039654cef1ab9ef32c6fafe283c0c0467bba1d9029738ce4a14a848", size = 196711, upload-time = "2026-06-02T14:34:07.794Z" }
|
||||||
|
wheels = [
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/1e/5e/d4e9f1a599fb8e573b7b87160658329fbf28d19eac2718f51fc3def3aa5a/idna-3.18-py3-none-any.whl", hash = "sha256:7f952cbe720b688055e3f87de14f5c3e5fdaa8bc3928985c4077ca689de849a2", size = 65455, upload-time = "2026-06-02T14:34:06.319Z" },
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "itsdangerous"
|
||||||
|
version = "2.2.0"
|
||||||
|
source = { registry = "https://pypi.org/simple" }
|
||||||
|
sdist = { url = "https://files.pythonhosted.org/packages/9c/cb/8ac0172223afbccb63986cc25049b154ecfb5e85932587206f42317be31d/itsdangerous-2.2.0.tar.gz", hash = "sha256:e0050c0b7da1eea53ffaf149c0cfbb5c6e2e2b69c4bef22c81fa6eb73e5f6173", size = 54410, upload-time = "2024-04-16T21:28:15.614Z" }
|
||||||
|
wheels = [
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/04/96/92447566d16df59b2a776c0fb82dbc4d9e07cd95062562af01e408583fc4/itsdangerous-2.2.0-py3-none-any.whl", hash = "sha256:c6242fc49e35958c8b15141343aa660db5fc54d4f13a1db01a3f5891b98700ef", size = 16234, upload-time = "2024-04-16T21:28:14.499Z" },
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "jinja2"
|
||||||
|
version = "3.1.6"
|
||||||
|
source = { registry = "https://pypi.org/simple" }
|
||||||
|
dependencies = [
|
||||||
|
{ name = "markupsafe" },
|
||||||
|
]
|
||||||
|
sdist = { url = "https://files.pythonhosted.org/packages/df/bf/f7da0350254c0ed7c72f3e33cef02e048281fec7ecec5f032d4aac52226b/jinja2-3.1.6.tar.gz", hash = "sha256:0137fb05990d35f1275a587e9aee6d56da821fc83491a0fb838183be43f66d6d", size = 245115, upload-time = "2025-03-05T20:05:02.478Z" }
|
||||||
|
wheels = [
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/62/a1/3d680cbfd5f4b8f15abc1d571870c5fc3e594bb582bc3b64ea099db13e56/jinja2-3.1.6-py3-none-any.whl", hash = "sha256:85ece4451f492d0c13c5dd7c13a64681a86afae63a5f347908daf103ce6d2f67", size = 134899, upload-time = "2025-03-05T20:05:00.369Z" },
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "markupsafe"
|
||||||
|
version = "3.0.3"
|
||||||
|
source = { registry = "https://pypi.org/simple" }
|
||||||
|
sdist = { url = "https://files.pythonhosted.org/packages/7e/99/7690b6d4034fffd95959cbe0c02de8deb3098cc577c67bb6a24fe5d7caa7/markupsafe-3.0.3.tar.gz", hash = "sha256:722695808f4b6457b320fdc131280796bdceb04ab50fe1795cd540799ebe1698", size = 80313, upload-time = "2025-09-27T18:37:40.426Z" }
|
||||||
|
wheels = [
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/e8/4b/3541d44f3937ba468b75da9eebcae497dcf67adb65caa16760b0a6807ebb/markupsafe-3.0.3-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:2f981d352f04553a7171b8e44369f2af4055f888dfb147d55e42d29e29e74559", size = 11631, upload-time = "2025-09-27T18:36:05.558Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/98/1b/fbd8eed11021cabd9226c37342fa6ca4e8a98d8188a8d9b66740494960e4/markupsafe-3.0.3-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:e1c1493fb6e50ab01d20a22826e57520f1284df32f2d8601fdd90b6304601419", size = 12057, upload-time = "2025-09-27T18:36:07.165Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/40/01/e560d658dc0bb8ab762670ece35281dec7b6c1b33f5fbc09ebb57a185519/markupsafe-3.0.3-cp310-cp310-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:1ba88449deb3de88bd40044603fafffb7bc2b055d626a330323a9ed736661695", size = 22050, upload-time = "2025-09-27T18:36:08.005Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/af/cd/ce6e848bbf2c32314c9b237839119c5a564a59725b53157c856e90937b7a/markupsafe-3.0.3-cp310-cp310-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:f42d0984e947b8adf7dd6dde396e720934d12c506ce84eea8476409563607591", size = 20681, upload-time = "2025-09-27T18:36:08.881Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/c9/2a/b5c12c809f1c3045c4d580b035a743d12fcde53cf685dbc44660826308da/markupsafe-3.0.3-cp310-cp310-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:c0c0b3ade1c0b13b936d7970b1d37a57acde9199dc2aecc4c336773e1d86049c", size = 20705, upload-time = "2025-09-27T18:36:10.131Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/cf/e3/9427a68c82728d0a88c50f890d0fc072a1484de2f3ac1ad0bfc1a7214fd5/markupsafe-3.0.3-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:0303439a41979d9e74d18ff5e2dd8c43ed6c6001fd40e5bf2e43f7bd9bbc523f", size = 21524, upload-time = "2025-09-27T18:36:11.324Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/bc/36/23578f29e9e582a4d0278e009b38081dbe363c5e7165113fad546918a232/markupsafe-3.0.3-cp310-cp310-musllinux_1_2_riscv64.whl", hash = "sha256:d2ee202e79d8ed691ceebae8e0486bd9a2cd4794cec4824e1c99b6f5009502f6", size = 20282, upload-time = "2025-09-27T18:36:12.573Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/56/21/dca11354e756ebd03e036bd8ad58d6d7168c80ce1fe5e75218e4945cbab7/markupsafe-3.0.3-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:177b5253b2834fe3678cb4a5f0059808258584c559193998be2601324fdeafb1", size = 20745, upload-time = "2025-09-27T18:36:13.504Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/87/99/faba9369a7ad6e4d10b6a5fbf71fa2a188fe4a593b15f0963b73859a1bbd/markupsafe-3.0.3-cp310-cp310-win32.whl", hash = "sha256:2a15a08b17dd94c53a1da0438822d70ebcd13f8c3a95abe3a9ef9f11a94830aa", size = 14571, upload-time = "2025-09-27T18:36:14.779Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/d6/25/55dc3ab959917602c96985cb1253efaa4ff42f71194bddeb61eb7278b8be/markupsafe-3.0.3-cp310-cp310-win_amd64.whl", hash = "sha256:c4ffb7ebf07cfe8931028e3e4c85f0357459a3f9f9490886198848f4fa002ec8", size = 15056, upload-time = "2025-09-27T18:36:16.125Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/d0/9e/0a02226640c255d1da0b8d12e24ac2aa6734da68bff14c05dd53b94a0fc3/markupsafe-3.0.3-cp310-cp310-win_arm64.whl", hash = "sha256:e2103a929dfa2fcaf9bb4e7c091983a49c9ac3b19c9061b6d5427dd7d14d81a1", size = 13932, upload-time = "2025-09-27T18:36:17.311Z" },
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "numpy"
|
||||||
|
version = "2.2.6"
|
||||||
|
source = { registry = "https://pypi.org/simple" }
|
||||||
|
sdist = { url = "https://files.pythonhosted.org/packages/76/21/7d2a95e4bba9dc13d043ee156a356c0a8f0c6309dff6b21b4d71a073b8a8/numpy-2.2.6.tar.gz", hash = "sha256:e29554e2bef54a90aa5cc07da6ce955accb83f21ab5de01a62c8478897b264fd", size = 20276440, upload-time = "2025-05-17T22:38:04.611Z" }
|
||||||
|
wheels = [
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/9a/3e/ed6db5be21ce87955c0cbd3009f2803f59fa08df21b5df06862e2d8e2bdd/numpy-2.2.6-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:b412caa66f72040e6d268491a59f2c43bf03eb6c96dd8f0307829feb7fa2b6fb", size = 21165245, upload-time = "2025-05-17T21:27:58.555Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/22/c2/4b9221495b2a132cc9d2eb862e21d42a009f5a60e45fc44b00118c174bff/numpy-2.2.6-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:8e41fd67c52b86603a91c1a505ebaef50b3314de0213461c7a6e99c9a3beff90", size = 14360048, upload-time = "2025-05-17T21:28:21.406Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/fd/77/dc2fcfc66943c6410e2bf598062f5959372735ffda175b39906d54f02349/numpy-2.2.6-cp310-cp310-macosx_14_0_arm64.whl", hash = "sha256:37e990a01ae6ec7fe7fa1c26c55ecb672dd98b19c3d0e1d1f326fa13cb38d163", size = 5340542, upload-time = "2025-05-17T21:28:30.931Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/7a/4f/1cb5fdc353a5f5cc7feb692db9b8ec2c3d6405453f982435efc52561df58/numpy-2.2.6-cp310-cp310-macosx_14_0_x86_64.whl", hash = "sha256:5a6429d4be8ca66d889b7cf70f536a397dc45ba6faeb5f8c5427935d9592e9cf", size = 6878301, upload-time = "2025-05-17T21:28:41.613Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/eb/17/96a3acd228cec142fcb8723bd3cc39c2a474f7dcf0a5d16731980bcafa95/numpy-2.2.6-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:efd28d4e9cd7d7a8d39074a4d44c63eda73401580c5c76acda2ce969e0a38e83", size = 14297320, upload-time = "2025-05-17T21:29:02.78Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/b4/63/3de6a34ad7ad6646ac7d2f55ebc6ad439dbbf9c4370017c50cf403fb19b5/numpy-2.2.6-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:fc7b73d02efb0e18c000e9ad8b83480dfcd5dfd11065997ed4c6747470ae8915", size = 16801050, upload-time = "2025-05-17T21:29:27.675Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/07/b6/89d837eddef52b3d0cec5c6ba0456c1bf1b9ef6a6672fc2b7873c3ec4e2e/numpy-2.2.6-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:74d4531beb257d2c3f4b261bfb0fc09e0f9ebb8842d82a7b4209415896adc680", size = 15807034, upload-time = "2025-05-17T21:29:51.102Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/01/c8/dc6ae86e3c61cfec1f178e5c9f7858584049b6093f843bca541f94120920/numpy-2.2.6-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:8fc377d995680230e83241d8a96def29f204b5782f371c532579b4f20607a289", size = 18614185, upload-time = "2025-05-17T21:30:18.703Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/5b/c5/0064b1b7e7c89137b471ccec1fd2282fceaae0ab3a9550f2568782d80357/numpy-2.2.6-cp310-cp310-win32.whl", hash = "sha256:b093dd74e50a8cba3e873868d9e93a85b78e0daf2e98c6797566ad8044e8363d", size = 6527149, upload-time = "2025-05-17T21:30:29.788Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/a3/dd/4b822569d6b96c39d1215dbae0582fd99954dcbcf0c1a13c61783feaca3f/numpy-2.2.6-cp310-cp310-win_amd64.whl", hash = "sha256:f0fd6321b839904e15c46e0d257fdd101dd7f530fe03fd6359c1ea63738703f3", size = 12904620, upload-time = "2025-05-17T21:30:48.994Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/9e/3b/d94a75f4dbf1ef5d321523ecac21ef23a3cd2ac8b78ae2aac40873590229/numpy-2.2.6-pp310-pypy310_pp73-macosx_10_15_x86_64.whl", hash = "sha256:0b605b275d7bd0c640cad4e5d30fa701a8d59302e127e5f79138ad62762c3e3d", size = 21040391, upload-time = "2025-05-17T21:44:35.948Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/17/f4/09b2fa1b58f0fb4f7c7963a1649c64c4d315752240377ed74d9cd878f7b5/numpy-2.2.6-pp310-pypy310_pp73-macosx_14_0_x86_64.whl", hash = "sha256:7befc596a7dc9da8a337f79802ee8adb30a552a94f792b9c9d18c840055907db", size = 6786754, upload-time = "2025-05-17T21:44:47.446Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/af/30/feba75f143bdc868a1cc3f44ccfa6c4b9ec522b36458e738cd00f67b573f/numpy-2.2.6-pp310-pypy310_pp73-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:ce47521a4754c8f4593837384bd3424880629f718d87c5d44f8ed763edd63543", size = 16643476, upload-time = "2025-05-17T21:45:11.871Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/37/48/ac2a9584402fb6c0cd5b5d1a91dcf176b15760130dd386bbafdbfe3640bf/numpy-2.2.6-pp310-pypy310_pp73-win_amd64.whl", hash = "sha256:d042d24c90c41b54fd506da306759e06e568864df8ec17ccc17e9e884634fd00", size = 12812666, upload-time = "2025-05-17T21:45:31.426Z" },
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "opencv-python"
|
||||||
|
version = "4.13.0.92"
|
||||||
|
source = { registry = "https://pypi.org/simple" }
|
||||||
|
dependencies = [
|
||||||
|
{ name = "numpy" },
|
||||||
|
]
|
||||||
|
wheels = [
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/fc/6f/5a28fef4c4a382be06afe3938c64cc168223016fa520c5abaf37e8862aa5/opencv_python-4.13.0.92-cp37-abi3-macosx_13_0_arm64.whl", hash = "sha256:caf60c071ec391ba51ed00a4a920f996d0b64e3e46068aac1f646b5de0326a19", size = 46247052, upload-time = "2026-02-05T07:01:25.046Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/08/ac/6c98c44c650b8114a0fb901691351cfb3956d502e8e9b5cd27f4ee7fbf2f/opencv_python-4.13.0.92-cp37-abi3-macosx_14_0_x86_64.whl", hash = "sha256:5868a8c028a0b37561579bfb8ac1875babdc69546d236249fff296a8c010ccf9", size = 32568781, upload-time = "2026-02-05T07:01:41.379Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/3e/51/82fed528b45173bf629fa44effb76dff8bc9f4eeaee759038362dfa60237/opencv_python-4.13.0.92-cp37-abi3-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:0bc2596e68f972ca452d80f444bc404e08807d021fbba40df26b61b18e01838a", size = 47685527, upload-time = "2026-02-05T06:59:11.24Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/db/07/90b34a8e2cf9c50fe8ed25cac9011cde0676b4d9d9c973751ac7616223a2/opencv_python-4.13.0.92-cp37-abi3-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:402033cddf9d294693094de5ef532339f14ce821da3ad7df7c9f6e8316da32cf", size = 70460872, upload-time = "2026-02-05T06:59:19.162Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/02/6d/7a9cc719b3eaf4377b9c2e3edeb7ed3a81de41f96421510c0a169ca3cfd4/opencv_python-4.13.0.92-cp37-abi3-manylinux_2_28_aarch64.whl", hash = "sha256:bccaabf9eb7f897ca61880ce2869dcd9b25b72129c28478e7f2a5e8dee945616", size = 46708208, upload-time = "2026-02-05T06:59:15.419Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/fd/55/b3b49a1b97aabcfbbd6c7326df9cb0b6fa0c0aefa8e89d500939e04aa229/opencv_python-4.13.0.92-cp37-abi3-manylinux_2_28_x86_64.whl", hash = "sha256:620d602b8f7d8b8dab5f4b99c6eb353e78d3fb8b0f53db1bd258bb1aa001c1d5", size = 72927042, upload-time = "2026-02-05T06:59:23.389Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/fb/17/de5458312bcb07ddf434d7bfcb24bb52c59635ad58c6e7c751b48949b009/opencv_python-4.13.0.92-cp37-abi3-win32.whl", hash = "sha256:372fe164a3148ac1ca51e5f3ad0541a4a276452273f503441d718fab9c5e5f59", size = 30932638, upload-time = "2026-02-05T07:02:14.98Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/e9/a5/1be1516390333ff9be3a9cb648c9f33df79d5096e5884b5df71a588af463/opencv_python-4.13.0.92-cp37-abi3-win_amd64.whl", hash = "sha256:423d934c9fafb91aad38edf26efb46da91ffbc05f3f59c4b0c72e699720706f5", size = 40212062, upload-time = "2026-02-05T07:02:12.724Z" },
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "pillow"
|
||||||
|
version = "12.2.0"
|
||||||
|
source = { registry = "https://pypi.org/simple" }
|
||||||
|
sdist = { url = "https://files.pythonhosted.org/packages/8c/21/c2bcdd5906101a30244eaffc1b6e6ce71a31bd0742a01eb89e660ebfac2d/pillow-12.2.0.tar.gz", hash = "sha256:a830b1a40919539d07806aa58e1b114df53ddd43213d9c8b75847eee6c0182b5", size = 46987819, upload-time = "2026-04-01T14:46:17.687Z" }
|
||||||
|
wheels = [
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/3a/aa/d0b28e1c811cd4d5f5c2bfe2e022292bd255ae5744a3b9ac7d6c8f72dd75/pillow-12.2.0-cp310-cp310-macosx_10_10_x86_64.whl", hash = "sha256:a4e8f36e677d3336f35089648c8955c51c6d386a13cf6ee9c189c5f5bd713a9f", size = 5354355, upload-time = "2026-04-01T14:42:15.402Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/27/8e/1d5b39b8ae2bd7650d0c7b6abb9602d16043ead9ebbfef4bc4047454da2a/pillow-12.2.0-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:2e589959f10d9824d39b350472b92f0ce3b443c0a3442ebf41c40cb8361c5b97", size = 4695871, upload-time = "2026-04-01T14:42:18.234Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/f0/c5/dcb7a6ca6b7d3be41a76958e90018d56c8462166b3ef223150360850c8da/pillow-12.2.0-cp310-cp310-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:a52edc8bfff4429aaabdf4d9ee0daadbbf8562364f940937b941f87a4290f5ff", size = 6269734, upload-time = "2026-04-01T14:42:20.608Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/ea/f1/aa1bb13b2f4eba914e9637893c73f2af8e48d7d4023b9d3750d4c5eb2d0c/pillow-12.2.0-cp310-cp310-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:975385f4776fafde056abb318f612ef6285b10a1f12b8570f3647ad0d74b48ec", size = 8076080, upload-time = "2026-04-01T14:42:23.095Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/a1/2a/8c79d6a53169937784604a8ae8d77e45888c41537f7f6f65ed1f407fe66d/pillow-12.2.0-cp310-cp310-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:bd9c0c7a0c681a347b3194c500cb1e6ca9cab053ea4d82a5cf45b6b754560136", size = 6382236, upload-time = "2026-04-01T14:42:25.82Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/b5/42/bbcb6051030e1e421d103ce7a8ecadf837aa2f39b8f82ef1a8d37c3d4ebc/pillow-12.2.0-cp310-cp310-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:88d387ff40b3ff7c274947ed3125dedf5262ec6919d83946753b5f3d7c67ea4c", size = 7070220, upload-time = "2026-04-01T14:42:28.68Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/3f/e1/c2a7d6dd8cfa6b231227da096fd2d58754bab3603b9d73bf609d3c18b64f/pillow-12.2.0-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:51c4167c34b0d8ba05b547a3bb23578d0ba17b80a5593f93bd8ecb123dd336a3", size = 6493124, upload-time = "2026-04-01T14:42:31.579Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/5f/41/7c8617da5d32e1d2f026e509484fdb6f3ad7efaef1749a0c1928adbb099e/pillow-12.2.0-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:34c0d99ecccea270c04882cb3b86e7b57296079c9a4aff88cb3b33563d95afaa", size = 7194324, upload-time = "2026-04-01T14:42:34.615Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/2d/de/a777627e19fd6d62f84070ee1521adde5eeda4855b5cf60fe0b149118bca/pillow-12.2.0-cp310-cp310-win32.whl", hash = "sha256:b85f66ae9eb53e860a873b858b789217ba505e5e405a24b85c0464822fe88032", size = 6376363, upload-time = "2026-04-01T14:42:37.19Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/e7/34/fc4cb5204896465842767b96d250c08410f01f2f28afc43b257de842eed5/pillow-12.2.0-cp310-cp310-win_amd64.whl", hash = "sha256:673aa32138f3e7531ccdbca7b3901dba9b70940a19ccecc6a37c77d5fdeb05b5", size = 7083523, upload-time = "2026-04-01T14:42:39.62Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/2d/a0/32852d36bc7709f14dc3f64f929a275e958ad8c19a6deba9610d458e28b3/pillow-12.2.0-cp310-cp310-win_arm64.whl", hash = "sha256:3e080565d8d7c671db5802eedfb438e5565ffa40115216eabb8cd52d0ecce024", size = 2463318, upload-time = "2026-04-01T14:42:42.063Z" },
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "pymycobot"
|
||||||
|
version = "4.0.5"
|
||||||
|
source = { registry = "https://pypi.org/simple" }
|
||||||
|
dependencies = [
|
||||||
|
{ name = "numpy" },
|
||||||
|
{ name = "pyserial" },
|
||||||
|
]
|
||||||
|
sdist = { url = "https://files.pythonhosted.org/packages/94/5d/17f9b745e32c8058c8a6391eea2f81623955c13596c6c5434add051877f8/pymycobot-4.0.5.tar.gz", hash = "sha256:42f3ba85203130bf2ee7c122ede37e4d148538644bf1ae2c01663cfe0aa90266", size = 239622, upload-time = "2026-06-12T02:32:54.922Z" }
|
||||||
|
wheels = [
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/c9/5f/ec6505555837d14f807cef4e9976f90807cac1c69b1ce8f1baad57ad89be/pymycobot-4.0.5-py3-none-any.whl", hash = "sha256:7ab6edef05d7ae4e17c543ba24ffbb4f4e504a1acde96f346d658b5aa0609690", size = 301487, upload-time = "2026-06-12T02:32:51.77Z" },
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "pyserial"
|
||||||
|
version = "3.5"
|
||||||
|
source = { registry = "https://pypi.org/simple" }
|
||||||
|
sdist = { url = "https://files.pythonhosted.org/packages/1e/7d/ae3f0a63f41e4d2f6cb66a5b57197850f919f59e558159a4dd3a818f5082/pyserial-3.5.tar.gz", hash = "sha256:3c77e014170dfffbd816e6ffc205e9842efb10be9f58ec16d3e8675b4925cddb", size = 159125, upload-time = "2020-11-23T03:59:15.045Z" }
|
||||||
|
wheels = [
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/07/bc/587a445451b253b285629263eb51c2d8e9bcea4fc97826266d186f96f558/pyserial-3.5-py2.py3-none-any.whl", hash = "sha256:c4451db6ba391ca6ca299fb3ec7bae67a5c55dde170964c7a14ceefec02f2cf0", size = 90585, upload-time = "2020-11-23T03:59:13.41Z" },
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "pyyaml"
|
||||||
|
version = "6.0.3"
|
||||||
|
source = { registry = "https://pypi.org/simple" }
|
||||||
|
sdist = { url = "https://files.pythonhosted.org/packages/05/8e/961c0007c59b8dd7729d542c61a4d537767a59645b82a0b521206e1e25c2/pyyaml-6.0.3.tar.gz", hash = "sha256:d76623373421df22fb4cf8817020cbb7ef15c725b9d5e45f17e189bfc384190f", size = 130960, upload-time = "2025-09-25T21:33:16.546Z" }
|
||||||
|
wheels = [
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/f4/a0/39350dd17dd6d6c6507025c0e53aef67a9293a6d37d3511f23ea510d5800/pyyaml-6.0.3-cp310-cp310-macosx_10_13_x86_64.whl", hash = "sha256:214ed4befebe12df36bcc8bc2b64b396ca31be9304b8f59e25c11cf94a4c033b", size = 184227, upload-time = "2025-09-25T21:31:46.04Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/05/14/52d505b5c59ce73244f59c7a50ecf47093ce4765f116cdb98286a71eeca2/pyyaml-6.0.3-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:02ea2dfa234451bbb8772601d7b8e426c2bfa197136796224e50e35a78777956", size = 174019, upload-time = "2025-09-25T21:31:47.706Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/43/f7/0e6a5ae5599c838c696adb4e6330a59f463265bfa1e116cfd1fbb0abaaae/pyyaml-6.0.3-cp310-cp310-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:b30236e45cf30d2b8e7b3e85881719e98507abed1011bf463a8fa23e9c3e98a8", size = 740646, upload-time = "2025-09-25T21:31:49.21Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/2f/3a/61b9db1d28f00f8fd0ae760459a5c4bf1b941baf714e207b6eb0657d2578/pyyaml-6.0.3-cp310-cp310-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:66291b10affd76d76f54fad28e22e51719ef9ba22b29e1d7d03d6777a9174198", size = 840793, upload-time = "2025-09-25T21:31:50.735Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/7a/1e/7acc4f0e74c4b3d9531e24739e0ab832a5edf40e64fbae1a9c01941cabd7/pyyaml-6.0.3-cp310-cp310-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:9c7708761fccb9397fe64bbc0395abcae8c4bf7b0eac081e12b809bf47700d0b", size = 770293, upload-time = "2025-09-25T21:31:51.828Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/8b/ef/abd085f06853af0cd59fa5f913d61a8eab65d7639ff2a658d18a25d6a89d/pyyaml-6.0.3-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:418cf3f2111bc80e0933b2cd8cd04f286338bb88bdc7bc8e6dd775ebde60b5e0", size = 732872, upload-time = "2025-09-25T21:31:53.282Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/1f/15/2bc9c8faf6450a8b3c9fc5448ed869c599c0a74ba2669772b1f3a0040180/pyyaml-6.0.3-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:5e0b74767e5f8c593e8c9b5912019159ed0533c70051e9cce3e8b6aa699fcd69", size = 758828, upload-time = "2025-09-25T21:31:54.807Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/a3/00/531e92e88c00f4333ce359e50c19b8d1de9fe8d581b1534e35ccfbc5f393/pyyaml-6.0.3-cp310-cp310-win32.whl", hash = "sha256:28c8d926f98f432f88adc23edf2e6d4921ac26fb084b028c733d01868d19007e", size = 142415, upload-time = "2025-09-25T21:31:55.885Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/2a/fa/926c003379b19fca39dd4634818b00dec6c62d87faf628d1394e137354d4/pyyaml-6.0.3-cp310-cp310-win_amd64.whl", hash = "sha256:bdb2c67c6c1390b63c6ff89f210c8fd09d9a1217a465701eac7316313c915e4c", size = 158561, upload-time = "2025-09-25T21:31:57.406Z" },
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "pyzbar"
|
||||||
|
version = "0.1.9"
|
||||||
|
source = { registry = "https://pypi.org/simple" }
|
||||||
|
wheels = [
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/6d/24/81ebe6a1c00760471a3028a23cbe0b94e5fa2926e5ba47adc895920887bc/pyzbar-0.1.9-py2.py3-none-any.whl", hash = "sha256:4559628b8192feb25766d954b36a3753baaf5c97c03135aec7e4a026036b475d", size = 32560, upload-time = "2022-03-15T14:53:40.637Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/8e/87/7b596730179ddf17857eea33ba820354dd4e1cf941e57f51ffccce26c409/pyzbar-0.1.9-py2.py3-none-win32.whl", hash = "sha256:8f4c5264c9c7c6b9f20d01efc52a4eba1ded47d9ba857a94130afe33703eb518", size = 810633, upload-time = "2022-03-15T14:53:43.446Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/0a/e2/1c6a8e94197612dbdfc51eab8dfb674168829885fac2c4f50ac8366c25ca/pyzbar-0.1.9-py2.py3-none-win_amd64.whl", hash = "sha256:13e3ee5a2f3a545204a285f41814d5c0db571967e8d4af8699a03afc55182a9c", size = 817363, upload-time = "2022-03-15T14:53:46.691Z" },
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "requests"
|
||||||
|
version = "2.34.2"
|
||||||
|
source = { registry = "https://pypi.org/simple" }
|
||||||
|
dependencies = [
|
||||||
|
{ name = "certifi" },
|
||||||
|
{ name = "charset-normalizer" },
|
||||||
|
{ name = "idna" },
|
||||||
|
{ name = "urllib3" },
|
||||||
|
]
|
||||||
|
sdist = { url = "https://files.pythonhosted.org/packages/ac/c3/e2a2b89f2d3e2179abd6d00ebd70bff6273f37fb3e0cc209f48b39d00cbf/requests-2.34.2.tar.gz", hash = "sha256:f288924cae4e29463698d6d60bc6a4da69c89185ad1e0bcc4104f584e960b9ed", size = 142856, upload-time = "2026-05-14T19:25:27.735Z" }
|
||||||
|
wheels = [
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/a0/f4/c67b0b3f1b9245e8d266f0f112c500d50e5b4e83cb6f3b71b6528104182a/requests-2.34.2-py3-none-any.whl", hash = "sha256:2a0d60c172f83ac6ab31e4554906c0f3b3588d37b5cb939b1c061f4907e278e0", size = 73075, upload-time = "2026-05-14T19:25:26.443Z" },
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "smart-inspection"
|
||||||
|
version = "0.1.0"
|
||||||
|
source = { virtual = "." }
|
||||||
|
dependencies = [
|
||||||
|
{ name = "flask" },
|
||||||
|
{ name = "flask-cors" },
|
||||||
|
{ name = "numpy" },
|
||||||
|
{ name = "opencv-python" },
|
||||||
|
{ name = "pillow" },
|
||||||
|
{ name = "pymycobot" },
|
||||||
|
{ name = "pyyaml" },
|
||||||
|
{ name = "pyzbar" },
|
||||||
|
{ name = "requests" },
|
||||||
|
{ name = "werkzeug" },
|
||||||
|
]
|
||||||
|
|
||||||
|
[package.metadata]
|
||||||
|
requires-dist = [
|
||||||
|
{ name = "flask", specifier = ">=2.0,<2.3" },
|
||||||
|
{ name = "flask-cors", specifier = ">=3.0" },
|
||||||
|
{ name = "numpy", specifier = ">=1.20" },
|
||||||
|
{ name = "opencv-python", specifier = ">=4.5" },
|
||||||
|
{ name = "pillow", specifier = ">=10.0" },
|
||||||
|
{ name = "pymycobot", specifier = ">=4.0.0" },
|
||||||
|
{ name = "pyyaml", specifier = ">=6.0" },
|
||||||
|
{ name = "pyzbar", specifier = ">=0.1.8" },
|
||||||
|
{ name = "requests", specifier = ">=2.25" },
|
||||||
|
{ name = "werkzeug", specifier = ">=2.2,<3.0" },
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "typing-extensions"
|
||||||
|
version = "4.15.0"
|
||||||
|
source = { registry = "https://pypi.org/simple" }
|
||||||
|
sdist = { url = "https://files.pythonhosted.org/packages/72/94/1a15dd82efb362ac84269196e94cf00f187f7ed21c242792a923cdb1c61f/typing_extensions-4.15.0.tar.gz", hash = "sha256:0cea48d173cc12fa28ecabc3b837ea3cf6f38c6d1136f85cbaaf598984861466", size = 109391, upload-time = "2025-08-25T13:49:26.313Z" }
|
||||||
|
wheels = [
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/18/67/36e9267722cc04a6b9f15c7f3441c2363321a3ea07da7ae0c0707beb2a9c/typing_extensions-4.15.0-py3-none-any.whl", hash = "sha256:f0fa19c6845758ab08074a0cfa8b7aecb71c999ca73d62883bc25cc018c4e548", size = 44614, upload-time = "2025-08-25T13:49:24.86Z" },
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "urllib3"
|
||||||
|
version = "2.7.0"
|
||||||
|
source = { registry = "https://pypi.org/simple" }
|
||||||
|
sdist = { url = "https://files.pythonhosted.org/packages/53/0c/06f8b233b8fd13b9e5ee11424ef85419ba0d8ba0b3138bf360be2ff56953/urllib3-2.7.0.tar.gz", hash = "sha256:231e0ec3b63ceb14667c67be60f2f2c40a518cb38b03af60abc813da26505f4c", size = 433602, upload-time = "2026-05-07T16:13:18.596Z" }
|
||||||
|
wheels = [
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/7f/3e/5db95bcf282c52709639744ca2a8b149baccf648e39c8cc87553df9eae0c/urllib3-2.7.0-py3-none-any.whl", hash = "sha256:9fb4c81ebbb1ce9531cce37674bbc6f1360472bc18ca9a553ede278ef7276897", size = 131087, upload-time = "2026-05-07T16:13:17.151Z" },
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "werkzeug"
|
||||||
|
version = "2.3.8"
|
||||||
|
source = { registry = "https://pypi.org/simple" }
|
||||||
|
dependencies = [
|
||||||
|
{ name = "markupsafe" },
|
||||||
|
]
|
||||||
|
sdist = { url = "https://files.pythonhosted.org/packages/3d/4b/d746f1000782c89d6c97df9df43ba8f4d126038608843d3560ae88d201b5/werkzeug-2.3.8.tar.gz", hash = "sha256:554b257c74bbeb7a0d254160a4f8ffe185243f52a52035060b761ca62d977f03", size = 819747, upload-time = "2023-11-08T18:37:03.303Z" }
|
||||||
|
wheels = [
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/fd/21/0a674dfe66e9df9072c46269c882e9f901d36d987d8ea50ead033a9c1e01/werkzeug-2.3.8-py3-none-any.whl", hash = "sha256:bba1f19f8ec89d4d607a3bd62f1904bd2e609472d93cd85e9d4e178f472c3748", size = 242332, upload-time = "2023-11-08T18:37:01.088Z" },
|
||||||
|
]
|
||||||
Reference in New Issue
Block a user