Compare commits

..

20 Commits

Author SHA1 Message Date
FaulknerWu d45a72c6a6 Update mission flow and inspection log scrolling 2026-06-22 16:13:38 +08:00
FaulknerWu 7dadcb8bcc Improve camera status and production startup 2026-06-22 13:54:07 +08:00
FaulknerWu 55f646053d Refactor ROS startup scripts 2026-06-22 13:06:31 +08:00
FaulknerWu 62cccfbcc6 Fix shell compatibility issues in prod-backend.sh
- Use bash -c explicitly for command substitution with source
- Fixes 'source: not found' errors when running subshells

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-22 12:35:12 +08:00
FaulknerWu cb6498cd2b Refactor infrastructure scripts and add mock hardware support
Changes:
- Refactor project scripts for better dev/prod workflow separation
- Add mock_hardware.py for local development without real hardware
- Add Makefile for common commands
- Add .env.example for environment variable reference
- Split scripts into dev-backend.sh, dev-frontend.sh, prod-backend.sh
- Add stop.sh for clean shutdown

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-22 12:31:32 +08:00
FaulknerWu 1429442dbd Rename customs-tablet-frontend to public-frontend and add new features
- Rename customs-tablet-frontend/ to public-frontend/ for broader scope
- Add new pages: customs, inspection with camera integration
- Add new services: apiClient.ts, backendApi.ts, normalizers.ts
- Add CameraFrame component for real-time video streaming
- Add scan_fixer module with clock_publisher and timestamp fix utilities
- Update startup scripts to support new frontend structure
- Update arm_server configuration and service files

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-22 10:18:20 +08:00
FaulknerWu 083d12016a Enhance inspection workspace 2026-06-20 11:20:04 +08:00
FaulknerWu f10ef75852 Add customs tablet frontend prototype 2026-06-20 01:25:07 +08:00
FaulknerWu 87060e30d4 Use uv for Python environment 2026-06-19 18:54:46 +08:00
FaulknerWu 7083c45feb Update project structure 2026-06-19 18:10:43 +08:00
ywb 52f1930f9a - 2026-06-16 16:24:31 +08:00
ywb 3d0bcc8f6f - 2026-06-16 14:55:38 +08:00
ywb fede57e69a - 2026-06-16 14:23:43 +08:00
ywb 916b44bc3c 查验 2026-06-16 14:17:05 +08:00
ywb 62292edc70 - 2026-06-13 15:56:09 +08:00
ywb cbc88def27 - 2026-06-13 14:07:19 +08:00
ywb 48121b2a05 - 2026-06-09 13:53:37 +08:00
ywb a4f4be4c8e - 2026-06-08 11:42:41 +08:00
ywb 696bf2ef6e - 2026-06-05 20:50:38 +08:00
ywb 4126e01bba 显示机械臂摄像头图片 2026-06-05 10:27:42 +08:00
83 changed files with 15046 additions and 3210 deletions
+21
View File
@@ -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
View File
@@ -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/
+1
View File
@@ -0,0 +1 @@
3.10
+31
View File
@@ -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` 中的默认路径与日志位置。
+57
View File
@@ -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
View File
@@ -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 摄像头单帧 JPEGpolling 模式)"""
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 摄像头单帧 JPEGpolling 模式)"""
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
View File
@@ -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 摄像头 video4Orbbec 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
View File
@@ -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
View File
@@ -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
] ]
} }
+12 -12
View File
@@ -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": ""
+44
View File
@@ -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": ""
}
]
-7
View File
@@ -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
-67
View File
@@ -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()
-17
View File
@@ -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
-5
View File
@@ -1,5 +0,0 @@
#!/bin/bash
# 启动 AGV 拍摄系统
cd ~/work/agv_app
python3 app.py
-17
View File
@@ -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
-338
View File
@@ -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"
-9
View File
@@ -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: $!"
+231 -2
View File
@@ -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;
}
+52 -2
View File
@@ -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')
+65 -7
View File
@@ -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) {
+183 -8
View File
@@ -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')
-91
View File
@@ -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
View File
File diff suppressed because it is too large Load Diff
+11 -4
View File
@@ -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">
+32 -6
View File
@@ -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
View File
@@ -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>
-161
View File
@@ -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()
+7 -7
View File
@@ -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
View File
@@ -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
-92
View File
@@ -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"
-663
View File
@@ -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
+163 -37
View File
@@ -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):
+382
View File
@@ -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:
"""连接 AGVMock"""
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
View File
@@ -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
+2 -2
View File
@@ -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
View File
@@ -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)
+18
View File
@@ -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
-3
View File
@@ -1,3 +0,0 @@
# 机械臂端依赖(最少依赖)
# RoboFlow 已在树莓派上运行,此端仅做透传
flask>=1.0,<2.3
+6 -8
View File
@@ -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
+882
View File
@@ -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 业务目标
自动巡检拍摄系统:AGVAutomated 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 3CDN+ 原生 JS + HTML/CSS |
| **机器人控制** | ROS2 Humble + nav2_simple_commander |
| **机械臂** | RoboFlow 630 → TCP Socketarm_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() # 打开摄像头(V4L2device_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 POSTmultipart/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.pyTCP 服务器端口 5002
- arm_camera.pyMJPEG 服务器端口 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`。
+151
View File
@@ -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 20577AGV 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个数据配置文件
- 结合记忆条目中的经验教训和已知问题
+3
View File
@@ -0,0 +1,3 @@
{
"extends": "next/core-web-vitals"
}
+9
View File
@@ -0,0 +1,9 @@
.next
node_modules
out
dist
.env*.local
npm-debug.log*
yarn-debug.log*
yarn-error.log*
pnpm-debug.log*
+9
View File
@@ -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
```
+19
View File
@@ -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;
+6458
View File
File diff suppressed because it is too large Load Diff
+33
View File
@@ -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"
}
}
+243
View File
@@ -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.
+64
View File
@@ -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;
}
+582
View File
@@ -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>
);
}
+49
View File
@@ -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>
);
}
+187
View File
@@ -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>
);
}
+262
View File
@@ -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>
);
}
+113
View File
@@ -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>
</>
);
};
+66
View File
@@ -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),
});
}
+280
View File
@@ -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 [];
},
};
+289
View File
@@ -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,
},
];
};
+39
View File
@@ -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,
})),
}));
+150
View File
@@ -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;
}
+26
View File
@@ -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"]
}
+21
View File
@@ -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
-31
View File
@@ -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
+68
View File
@@ -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()
+85
View File
@@ -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()
+104
View File
@@ -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`
+32
View File
@@ -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
+26
View File
@@ -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
+259
View File
@@ -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 ""
+358
View File
@@ -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"
+34
View File
@@ -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 "完成"
+16
View File
@@ -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
-23
View File
@@ -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"
}
}
-24
View File
@@ -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
Generated
+320
View File
@@ -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" },
]