Compare commits

...

13 Commits

Author SHA1 Message Date
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
68 changed files with 12422 additions and 2380 deletions
+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
+526 -50
View File
@@ -8,10 +8,11 @@ import time
import logging
import threading
import subprocess
import requests
from flask import Flask, render_template, jsonify, request, Response, send_from_directory
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 SERVER_CONFIG, ARM_CONFIG, AGV_CONFIG, UPLOAD_CONFIG, MAP_CONFIG, ARM_CAMERA_CONFIG, CAMERA_CONFIG, DATA_DIR, State, ZHIJIAN_BASE_URL, ZHIJIAN_AUTH_TOKEN, set_api_mode
from utils.arm_client import ArmClient
from utils.agv_controller_ros2 import AGVController
from utils.qr_scanner import QRScanner
@@ -53,6 +54,8 @@ class GlobalState:
self.machines_config = [] # 机器配置(每台机器的正面/背面点位+姿态)
self.qr_config = [] # 二维码配置(独立点位列表)
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.lock = threading.Lock()
@@ -126,10 +129,11 @@ try:
# Flask 2.3+ 方式
with app.app_context():
load_persisted_config()
# 启动时自动重连 AGV(异步,不阻塞 Flask 启动)
# 启动时自动连接所有设备(异步,不阻塞 Flask 启动)
import threading
def _auto_reconnect():
def _auto_connect_all():
time.sleep(2) # 等待 Flask 完全就绪
# 连接 AGV
try:
from utils.agv_controller_ros2 import AGVController
gs.agv_controller = AGVController()
@@ -139,7 +143,19 @@ try:
print("[启动] AGV 自动连接失败,请手动连接")
except Exception as e:
print(f"[启动] AGV 自动连接异常: {e}")
threading.Thread(target=_auto_reconnect, daemon=True).start()
# 连接机械臂
try:
from utils.arm_client import ArmClient
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:
# 兼容旧版 Flask
@app.before_first_request
@@ -175,15 +191,21 @@ def api_status():
arm_connected = ok
except:
arm_connected = False
# 连接已断开,清理 socket
if gs.arm_client:
gs.arm_client._sock = None
# 实际验证 AGV 连接
agv_connected = False
if gs.agv_controller:
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({
"state": gs.state,
"agv_connected": agv_connected,
@@ -747,11 +769,7 @@ def api_mission_config_set():
gs.mission_config["cols"] = cols
gs.mission_config["grid"] = grid
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)
return jsonify({"ok": True, "config": gs.mission_config})
@@ -1070,33 +1088,25 @@ def api_camera_preview():
if not gs.qr_scanner or not gs.qr_scanner._cap:
return "camera not opened", 400
import time as _time
def gen():
_last_ok = _time.time()
while True:
frame = gs.qr_scanner.read_frame()
if frame is None:
if _time.time() - _last_ok > 5:
break
# 编码为 JPEG
_time.sleep(0.05)
continue
import cv2
ret, buf = cv2.imencode(".jpg", frame)
if ret:
_last_ok = _time.time()
yield (b"--frame\r\nContent-Type: image/jpeg\r\n\r\n" +
buf.tobytes() + b"\r\n")
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")
def api_camera_capture():
@@ -1114,13 +1124,82 @@ def api_camera_capture():
cv2.imwrite(photo_path, frame)
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")
def api_arm_camera_refresh():
"""从机械臂拉一张 JPEG(请求 snapshot 端点,简单 HTTP GET"""
"""从机械臂拉一张 JPEG,翻转后返回(机械臂摄像头物理倒装)。"""
import requests
import cv2
import numpy as np
url = ARM_CAMERA_CONFIG.get("snapshot_url", ARM_CAMERA_CONFIG["url"])
max_retries = 3
for attempt in range(1, max_retries + 1):
try:
r = requests.get(ARM_CAMERA_CONFIG.get("snapshot_url", ARM_CAMERA_CONFIG["url"]), timeout=8)
r = requests.get(url, timeout=8)
if r.status_code == 200 and r.content:
corruption = _is_corrupted_jpeg(r.content)
if corruption > 0.5:
logger.warning(f"arm_refresh 第{attempt}次尝试检测到花屏 (置信度{corruption:.2f}),重试...")
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"
@@ -1128,13 +1207,15 @@ def api_arm_camera_refresh():
return resp
return "", 404
except Exception as ex:
logger.info(f"arm_refresh 不可用: {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")
def api_arm_camera_preview():
"""代理机械臂 MJPEG 视频流,供页面连续预览"""
"""代理机械臂 MJPEG 视频流,直透返回(不翻转)"""
import requests
try:
upstream = requests.get(
@@ -1147,10 +1228,23 @@ def api_arm_camera_preview():
return "", 404
def generate():
buf = b""
try:
for chunk in upstream.iter_content(chunk_size=8192):
if chunk:
yield chunk
if not 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:
upstream.close()
@@ -1179,7 +1273,7 @@ def api_agv_move():
"""控制 AGV 移动(前进/后退/左转/右转/停止)"""
data = request.json
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():
return jsonify({"ok": False, "error": "AGV 未连接"}), 400
try:
@@ -1242,6 +1336,11 @@ def api_agv_reset():
def api_mission_start():
"""开始执行任务(V3: M×N Grid 蛇形路径)"""
data = request.json or {}
# 必须先设置报关单(开始查验)
if not gs.inspection:
return jsonify({"ok": False, "error": "请先在「设置→报关单」中选择报关单并点击「开始查验」"}), 400
single_step = bool(data.get("single_step", False))
# 任务步骤控制开关
options = {
@@ -1250,8 +1349,8 @@ def api_mission_start():
"qr_scan": bool(data.get("qr_scan", True)),
"front_photo": bool(data.get("front_photo", True)),
"back_photo": bool(data.get("back_photo", True)),
"agv_speed": float(data.get("agv_speed", 0.5)),
"arm_speed": int(data.get("arm_speed", 500)),
"agv_speed": float(data.get("agv_speed", 1.0)),
"arm_speed": int(data.get("arm_speed", 1000)),
}
print(f"[Mission] options: {options}")
@@ -1436,6 +1535,18 @@ def api_mission_state():
result["waiting_step"] = 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)
@app.route("/api/mission/log", methods=["GET"])
@@ -1574,31 +1685,61 @@ def api_qr_read_angles(qr_id):
@app.route("/api/qr/scan/<qr_id>", methods=["POST"])
def api_qr_config_scan(qr_id):
"""获取机械臂摄像头图像,识别二维码并保存到指定配置项"""
"""获取机械臂摄像头图像,识别二维码并保存到指定配置项pyzbar 优先,OpenCV 兜底)"""
import requests
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 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)
frame = cv2.imdecode(nparr, cv2.IMREAD_COLOR)
if frame is None:
return jsonify({"ok": False, "error": "图像解码失败"}), 400
# 使用 OpenCV QRCodeDetector 检测
result = None
# 方法1: pyzbar(识别率更高)
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()
result, _, _ = detector.detectAndDecode(frame)
if result and len(result.strip()) > 0:
result = result.strip()
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:
if entry["id"] == qr_id:
entry["qr_value"] = result
# 尝试匹配机型
matched_model = None
for model in gs.models_config:
prefix = model.get("serial_prefix", "")
@@ -1615,19 +1756,353 @@ def api_qr_config_scan(qr_id):
"model_name": matched_model["name"] if matched_model else ""
})
return jsonify({"ok": False, "error": f"二维码 {qr_id} 不存在"}), 404
else:
return jsonify({"ok": False, "error": "未检测到二维码"})
return jsonify({"ok": False, "error": "未检测到二维码,请调整机械臂姿态或手动输入"})
except Exception as ex:
logger.error(f"QR 扫描机械臂摄像头失败: {ex}")
logger.error(f"QR 扫描失败: {ex}")
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>")
def 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.qr_scanner._cap or not gs.qr_scanner._cap.isOpened():
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": False, # Orbbec 深度相机不提供可用的彩色画面
"has_arm_camera": True,
})
# ========== 启动 ==========
if __name__ == "__main__":
logger.info("=" * 50)
@@ -1654,3 +2129,4 @@ if __name__ == "__main__":
debug=SERVER_CONFIG["debug"],
threaded=True
)
+28 -7
View File
@@ -7,15 +7,15 @@ import os
BASE_DIR = os.path.dirname(os.path.abspath(__file__))
# ========== 网络配置(集中管理所有 IP 地址 — 修改此处即可全局生效)==========
AGV_HOST = "192.168.60.177"
ARM_HOST = "192.168.60.88"
AGV_HOST = "192.168.60.80"
ARM_HOST = "192.168.60.120"
# ========== AGV 参数 ==========
AGV_CONFIG = {
"device": "/dev/agvpro_controller",
"baudrate": 10000000,
"move_speed": 0.5,
"turn_speed": 0.5,
"move_speed": 1.0,
"turn_speed": 1.0,
}
# ========== 机械臂 TCP 客户端 ==========
@@ -35,7 +35,7 @@ MAP_CONFIG = {
# ========== 摄像头 ==========
CAMERA_CONFIG = {
"device_index": 4, # AGV 摄像头 video4标准彩色摄像头,V4L2后端
"device_index": 4, # AGV 摄像头 video4Orbbec Gemini 彩色流,YUYV 640x480
"backend": "v4l2", # 使用 V4L2 后端获取标准彩色格式(640x480)
"qr_detect_interval": 0.5,
"capture_delay": 0.5,
@@ -47,9 +47,30 @@ ARM_CAMERA_CONFIG = {
"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 上传 ==========
UPLOAD_CONFIG = {
"url": "https://ts.zhijian168.com/prod-api/file/uploadImage",
"url": f"{ZHIJIAN_BASE_URL}{API_PREFIX}/file/uploadImage",
"timeout": 30,
"max_retries": 3,
}
@@ -77,7 +98,7 @@ JOINT_LIMITS = {
}
# ========== 机械臂默认速度 ==========
DEFAULT_ARM_SPEED = 500
DEFAULT_ARM_SPEED = 1000
# ========== 状态定义 ==========
class State:
+44
View File
@@ -0,0 +1,44 @@
[
{
"id": "qr_1779278140334",
"name": "二维码1",
"joint_angles": [
-89.796645,
-2.013175,
-87.176721,
-82.49663,
-93.323403,
20.399941
],
"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
-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.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 {
background: #1a2332;
@@ -465,6 +508,10 @@ a:hover { text-decoration: underline; }
aspect-ratio: 4/3;
object-fit: cover;
}
.camera-img.arm {
/* no flip */
}
.camera-placeholder {
width: 100%;
aspect-ratio: 4/3;
@@ -1057,10 +1104,10 @@ a:hover { text-decoration: underline; }
@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-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; }
/* 步骤圆点 */
@@ -1076,3 +1123,185 @@ a:hover { text-decoration: underline; }
.machine-cell.mstatus-pending { background: #141e28; border-color: #2a3a4a; }
.machine-cell.mstatus-active { background: #1a2535; border-color: #4fc3f7; }
.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;
}
+51 -1
View File
@@ -18,8 +18,11 @@ createApp({
agvCameraSrc: '/api/camera/refresh?t=' + Date.now(),
armCameraSrc: '/api/camera/arm_preview?t=' + Date.now(),
agvCameraError: false,
hasAgvCamera: false, // AGV 车体是否有可用相机
armCameraError: false,
reconnectingDevice: null
reconnectingDevice: null,
// 环境切换
testMode: true,
}
},
computed: {
@@ -36,6 +39,8 @@ createApp({
},
mounted() {
this.refresh()
this.refreshCameraCapabilities()
this.loadEnvMode()
setInterval(this.refreshStatus, 3000)
this.refreshCams()
setInterval(() => this.refreshCams(), 2000)
@@ -47,6 +52,17 @@ createApp({
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() {
await this.refreshStatus()
await this.loadPoints()
@@ -58,6 +74,10 @@ createApp({
this.agvConnected = data.agv_connected
this.armConnected = data.arm_connected
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.mapLoaded = data.map_loaded
this.currentState = data.state || 'idle'
@@ -116,6 +136,36 @@ createApp({
} else {
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')
+65 -7
View File
@@ -9,6 +9,7 @@ createApp({
tasks: [],
report: null,
armCameraOpened: false,
hasAgvCamera: false,
agvPreviewUrl: API + '/api/camera/preview',
armPreviewUrl: '',
polling: null,
@@ -27,14 +28,17 @@ createApp({
errorMsg: '',
waitingStep: false,
stepLabel: '',
qrMessage: '所有姿态均未识别到二维码,请手动输入:',
// 任务步骤控制开关(机械臂初始化并入AGV移动)
agvMoveEnabled: true,
qrScanEnabled: true,
frontPhotoEnabled: true,
backPhotoEnabled: true,
// 速度控制
agvSpeed: 0.5,
armSpeed: 500,
agvSpeed: 1.0,
armSpeed: 1000,
// 查验
inspection: null,
}
},
computed: {
@@ -50,6 +54,14 @@ createApp({
}
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() {
this.poll()
@@ -63,13 +75,20 @@ createApp({
try {
const res = await fetch(API + '/api/status')
const data = await res.json()
if (!this.armCameraOpened) {
this.armPreviewUrl = ''
} else if (!this.armPreviewUrl) {
this.armPreviewUrl = API + '/api/camera/arm_preview'
const opened = data.arm_camera_opened
if (opened !== this.armCameraOpened || (opened && !this.armPreviewUrl)) {
this.armCameraOpened = opened
this.armPreviewUrl = opened ? API + '/api/camera/arm_preview' : ''
}
} 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() {
this.refresh()
this.pollLogs()
@@ -93,7 +112,11 @@ createApp({
if (data.grid) this.missionGrid = data.grid
if (data.point_status) this.pointStatus = data.point_status
if (data.machine_status) this.machineStatus = data.machine_status
if (data.inspection) this.inspection = data.inspection
this.armCameraOpened = data.arm_camera_opened
if (this.armCameraOpened && !this.armPreviewUrl) {
this.armPreviewUrl = API + '/api/camera/arm_preview'
}
// 错误弹窗
if (data.waiting_error) {
@@ -111,6 +134,11 @@ createApp({
this.waitingStep = false
}
// QR 弹窗消息
if (data.qr_message) {
this.qrMessage = data.qr_message
}
// QR 弹窗(防止提交后重复弹出)
if (this.missionState !== 'waiting_qr') {
this.qrSubmitting = false
@@ -118,6 +146,9 @@ createApp({
if (this.missionState === 'waiting_qr' && !this.showQrModal && !this.qrSubmitting) {
this.showQrModal = true
this.qrValue = ''
if (!this.qrMessage) {
this.qrMessage = '所有姿态均未识别到二维码,请手动输入:'
}
}
// 完成后获取报告
@@ -145,6 +176,11 @@ createApp({
},
async startMission() {
if (this.missionState !== 'idle') return
// 没有设置报关单时阻止启动(后端也会校验,这里提前友好提示)
if (!this.inspection) {
alert('⚠️ 请先在「设置→报关单」中选择报关单并点击「开始查验」')
return
}
this.logs = []
this.progress = 0
this.report = null
@@ -175,6 +211,11 @@ createApp({
},
async startSingleStep() {
if (this.missionState !== 'idle') return
// 没有设置报关单时阻止启动(后端会校验,这里提前友好提示)
if (!this.inspection) {
alert('⚠️ 请先在「设置→报关单」中选择报关单并点击「开始查验」')
return
}
this.logs = []
this.progress = 0
this.report = null
@@ -244,11 +285,28 @@ createApp({
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) {
e.target.style.display = 'none'
},
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) {
+178 -4
View File
@@ -45,7 +45,7 @@ createApp({
agvConnected: false,
agvBattery: null,
agvPosition: null,
agvSpeed: 0.5,
agvSpeed: 1.0,
agvMoveInterval: null,
agvCameraUrl: API + '/api/camera/refresh',
agvCameraTimer: null,
@@ -57,9 +57,24 @@ createApp({
qrScanning: false,
qrConfigs: [],
qrScanningId: null,
showQrInputDialog: false,
qrInputId: null,
qrInputValue: '',
armCameraUrl: API + '/api/camera/arm_preview',
armSnapshotUrl: '',
showArmSnapshot: false,
armSnapshotLoading: false,
newQrName: '',
armInitialPose: [0, 0, 0, 0, 0, 0],
// 报关单
customsList: [],
customsLoading: false,
customsPage: 1,
customsPageSize: 15,
customsTotal: 0,
selectedCustomsId: '',
selectedCustomsName: '',
customsMachines: [],
}
},
mounted() {
@@ -67,9 +82,17 @@ createApp({
this.refreshAngles()
this.loadQrConfigs()
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: {
customsTotalPages() {
return Math.max(1, Math.ceil(this.customsTotal / this.customsPageSize))
},
customsPageData() {
// 前端显示 pagination data — 但我们在 API 后端做分页,所以这里只是引用
return this.customsList
},
hasQr() {
return !!(this.selectedMachine && this.selectedMachine.qr)
},
@@ -1104,10 +1127,39 @@ createApp({
if (data.model_name) msg += ' 匹配机型: ' + data.model_name
else msg += ' 未匹配到机型'
alert(msg)
} else { alert(data.error || '扫描失败') }
} catch (e) { alert('扫描失败: ' + e.message) }
} else {
// 自动扫描失败,弹出手动输入框
this.qrInputId = qrId
this.qrInputValue = ''
this.showQrInputDialog = true
}
} catch (e) {
this.qrInputId = qrId
this.qrInputValue = ''
this.showQrInputDialog = true
}
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) {
if (!this.armConnected) { alert('机械臂未连接'); return }
const q = this.qrConfigs.find(x => x.id === qrId)
@@ -1126,6 +1178,12 @@ createApp({
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() {
if (!this.agvConnected) {
alert('AGV 未连接')
@@ -1146,5 +1204,121 @@ createApp({
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')
-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="/running" class="nav-link">▶️ 运行</a>
</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">
{% raw %}{{ statusText }}{% endraw %}
</span>
@@ -93,9 +99,10 @@
<h2>📷 摄像头预览</h2>
<div class="camera-row">
<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>
<img v-if="cameraOpened && !agvCameraError" :src="agvCameraSrc" class="camera-img" @error="agvCameraError=true">
<div v-if="cameraOpened && agvCameraError" class="camera-placeholder">AGV 摄像头异常</div>
<div class="camera-label">AGV 摄像头 <button class="btn btn-small" @click="refreshAgvCamera()">刷新</button></div>
<img v-if="cameraOpened && hasAgvCamera && !agvCameraError" :src="agvCameraSrc" class="camera-img" @error="agvCameraError=true">
<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>
<div class="camera-box">
+32 -6
View File
@@ -4,7 +4,7 @@
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>运行监控 - AGV 拍摄系统</title>
<link rel="stylesheet" href="/static/css/style.css?v=20260529a">
<link rel="stylesheet" href="/static/css/style.css?v=20260616a">
</head>
<body>
<div id="app">
@@ -48,6 +48,30 @@
</div>
</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">
<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,'back')" title="背面照">📸背</span>
</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>
<div v-else class="empty-cell"></div>
</div>
@@ -173,8 +197,9 @@
<h2>📷 摄像头预览</h2>
<div class="camera-dual">
<div class="camera-box">
<div class="camera-label">🎥 AGV 摄像头</div>
<img :src="agvPreviewUrl" @error="onAgvPreviewError" class="camera-img">
<div class="camera-label">🎥 AGV 摄像头 <span v-if="!hasAgvCamera" style="font-size:0.8em;color:#999">(不可用)</span></div>
<img v-if="hasAgvCamera" :src="agvPreviewUrl" @error="onAgvPreviewError" class="camera-img">
<div v-else class="camera-placeholder">AGV 无可用彩色摄像头</div>
</div>
<div class="camera-box" v-if="armCameraOpened">
<div class="camera-label">🦾 机械臂摄像头</div>
@@ -201,9 +226,10 @@
<div class="modal-overlay" v-if="showQrModal">
<div class="modal">
<h3>⌨️ 手动输入二维码</h3>
<p>所有姿态均未识别到二维码,请手动输入:</p>
<p>{% raw %}{{ qrMessage }}{% endraw %}</p>
<input type="text" v-model="qrValue" placeholder="输入二维码内容" autofocus @keyup.enter="submitQr">
<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" @click="cancelQr">跳过</button>
</div>
@@ -239,6 +265,6 @@
</div>
<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>
</html>
+144 -11
View File
@@ -4,7 +4,7 @@
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>设置 - AGV 拍摄系统</title>
<link rel="stylesheet" href="/static/css/style.css?v=20260529b">
<link rel="stylesheet" href="/static/css/style.css?v=20260612b">
</head>
<body>
<div id="app">
@@ -25,6 +25,7 @@
<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 === 'agv'}" @click="tab = 'agv'">🚗 AGV控制</button>
<button class="tab" :class="{active: tab === 'customs'}" @click="tab = 'customs'">📋 报关单</button>
</div>
<main class="container">
@@ -109,7 +110,7 @@
<thead>
<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">机型名称</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>
@@ -160,7 +161,7 @@
<div style="margin-top:8px">
<div style="display:flex;gap:8px;align-items:center;flex-wrap:wrap">
<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">
<button class="btn btn-secondary btn-small" @click="addPose(m.id, 'front', newPoseForm[m.id + '_front'])"> 添加正面姿态(当前角度)</button>
</div>
@@ -207,7 +208,7 @@
<div style="margin-top:8px">
<div style="display:flex;gap:8px;align-items:center;flex-wrap:wrap">
<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">
<button class="btn btn-secondary btn-small" @click="addPose(m.id, 'back', newPoseForm[m.id + '_back'])"> 添加背面姿态(当前角度)</button>
</div>
@@ -224,7 +225,7 @@
<button class="btn-icon" @click="showAddModelModal = false"></button>
</div>
<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">
</div>
<div class="form-group" style="margin-bottom:12px">
@@ -433,13 +434,18 @@
<h2>📷 二维码配置</h2>
<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">
<img :src="armCameraUrl" @error="onArmPreviewError" style="width:100%;border-radius:8px">
<img :src="armCameraUrl" @error="onArmPreviewError" class="camera-img arm">
</div>
</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-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>
</div>
<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">
<thead>
<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">J2</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-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-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>
</td>
</tr>
@@ -493,7 +500,7 @@
</div>
<div v-else>
<div class="camera-preview">
<img :src="armCameraUrl" @error="onArmPreviewError">
<img :src="armCameraUrl" @error="onArmPreviewError" class="camera-img arm">
</div>
<div class="joints-panel">
<h3>关节角度控制</h3>
@@ -581,10 +588,136 @@
</div>
</section>
</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>
<!-- 手动输入二维码弹窗 -->
<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/setting.js?v=20260529b"></script>
<script src="/static/js/setting.js?v=20260616f"></script>
</body>
</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:
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():
return
@@ -85,7 +85,7 @@ class AGVController:
time.sleep(duration)
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():
return
@@ -94,7 +94,7 @@ class AGVController:
time.sleep(duration)
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():
return
@@ -103,7 +103,7 @@ class AGVController:
time.sleep(duration)
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():
return
@@ -112,7 +112,7 @@ class AGVController:
time.sleep(duration)
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():
return
@@ -121,7 +121,7 @@ class AGVController:
time.sleep(duration)
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():
return
@@ -176,7 +176,7 @@ class AGVController:
return self._position
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 导航栈)"""
logger.warning("go_to_point 需要 ROS2 Nav2 支持,当前仅记录目标")
return True
+21 -12
View File
@@ -61,14 +61,18 @@ class ArmClient:
def get_angles(self) -> Tuple[bool, List[float]]:
"""获取所有关节角度"""
ok, resp = self.send_command("get_angles()")
if ok and resp.startswith("get_angles:["):
if ok:
try:
# get_angles:[0.174, 0.520, ...] → list
nums = resp.split("[")[1].split("]")[0]
angles = [float(x) for x in nums.split(",")]
# 兼容 "get_angles:[-260.2,...]" 和 "[-260.2,...]" 两种格式
text = resp.split(":", 1)[-1] if ":" in resp else resp
text = text.strip()
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:
return False, []
pass
return False, []
def set_angles(self, angles: List[float], speed: int = 500) -> bool:
@@ -94,13 +98,17 @@ class ArmClient:
def get_coords(self) -> Tuple[bool, List[float]]:
"""获取当前坐标和姿态 [x, y, z, rx, ry, rz]"""
ok, resp = self.send_command("get_coords()")
if ok and "get_coords:" in resp:
if ok:
try:
nums = resp.split("[")[1].split("]")[0]
coords = [float(x) for x in nums.split(",")]
text = resp.split(":", 1)[-1] if ":" in resp else resp
text = text.strip()
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:
return False, []
pass
return False, []
def set_coords(self, coords: List[float], speed: int = 500) -> bool:
@@ -132,19 +140,20 @@ class ArmClient:
def state_check(self) -> bool:
"""检查机械臂状态是否正常"""
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:
"""检查机械臂是否在运行"""
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:
"""等待上一条命令执行完成"""
start = time.time()
while time.time() - start < timeout:
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
time.sleep(0.5)
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
+141 -23
View File
@@ -28,10 +28,10 @@ from utils.nav2_navigator import Nav2Navigator, Nav2Status
logger = logging.getLogger(__name__)
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"]
PHOTOS_DIR = "/home/elephant/photos"
UPLOAD_URL = "https://ts.zhijian168.com/prod-api/file/uploadImage"
# UPLOAD_CONFIG["url"] 随环境切换动态变化,每次使用时直接读取
# 二维码扫描重试参数
QR_SCAN_TIMEOUT = 5 # 单次扫描超时
@@ -101,11 +101,10 @@ class MissionExecutorV3:
self._nav = Nav2Navigator()
# 速度控制(默认值,可在 execute_mission 时覆写)
self.arm_speed = 500
self.agv_speed = 0.5
self.arm_speed = 1000
self.agv_speed = 1.0
# 照片上传序号计数器(连续递增,从1开始)
self.next_upload_index = 1
# ==================== 连接 ====================
@@ -239,7 +238,6 @@ class MissionExecutorV3:
self._log(f"📍 点位蛇形路径: {len(path)} 个点位, {total_machines} 台机器")
# 重置照片上传序号(每次任务开始时重置,从1开始)
self.next_upload_index = 1
# 任务步骤控制开关
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)
# 速度控制(从前端传入)
self.arm_speed = int(options.get("arm_speed", 500))
self.agv_speed = float(options.get("agv_speed", 0.5))
self.arm_speed = int(options.get("arm_speed", 1000))
self.agv_speed = float(options.get("agv_speed", 1.0))
self._log(f"🚀 AGV速度={self.agv_speed:.1f}m/s, 机械臂速度={self.arm_speed}")
# 设置 Nav2 导航速度(仅在任务开始时设一次)
@@ -721,6 +719,7 @@ class MissionExecutorV3:
try:
resp = requests.get(ARM_CAMERA_SNAPSHOT, timeout=QR_SCAN_TIMEOUT)
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
arr = np.frombuffer(resp.content, dtype=np.uint8)
@@ -737,15 +736,33 @@ class MissionExecutorV3:
time.sleep(0.5)
return None
def _request_manual_qr(self) -> Optional[str]:
"""暂停任务,等待手动输入(不超时,必须输入才能继续;stop 时解除)"""
def _request_manual_qr(self, message: str = None) -> Optional[str]:
"""暂停任务,等待手动输入(支持重新扫描)
message: 自定义弹窗消息(None 则使用默认消息)"""
while True:
self.status = MissionStatus.WAITING_QR
self.report["status"] = "waiting_qr"
self.report["step"] = "等待手动输入二维码"
self._log(" ⌨️ 弹窗等待手动输入二维码(不可跳过)...")
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.wait() # 无限等待,直到 set_manual_qr 或 stop() 触发
if self._qr_value == 'RESCAN':
self.status = MissionStatus.RUNNING
self.report["status"] = "running"
self._log(" 🔄 用户点击重新扫描,重试...")
qr = self._decode_qr_from_arm()
if qr:
self._log(f" ✅ 重新扫描成功: {qr}")
return qr
self._log(" ❌ 重新扫描仍未识别到二维码")
continue # 继续弹窗
self.status = MissionStatus.RUNNING
self.report["status"] = "running"
if self._qr_value:
@@ -762,8 +779,73 @@ class MissionExecutorV3:
# ==================== 机型查询 ====================
def _lookup_model(self, qr_value: Optional[str]) -> str:
"""TODO: 后续通过 HTTP 接口查询机型"""
"""通过 /api/customs/printer 接口查询机型,同时更新查验计数
如果机型不在当前报关单中/超量/查询失败,均弹窗要求重新扫码/输入(不可跳过)"""
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
def _find_model(models: list, name: str) -> Optional[dict]:
@@ -795,6 +877,13 @@ class MissionExecutorV3:
self._log(f" ⚠️ 机型无{side_label}姿态配置")
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)} 个姿态)")
for pi, pose in enumerate(poses):
if self._stop.is_set():
@@ -818,9 +907,9 @@ class MissionExecutorV3:
self.arm_client.set_angles(angles, speed=self.arm_speed)
self._wait_arm_ready(angles)
# 拍照upload_index 连续递增)
path = self._capture_arm_photo(row, col, side, pi + 1, qr_value, upload_index=self.next_upload_index)
self.next_upload_index += 1
# 拍照:正面从1开始,背面接着正面数量继续编号
upload_index = base_index + pi + 1
path = self._capture_arm_photo(row, col, side, pi + 1, qr_value, upload_index=upload_index)
if path:
self._log(f" 💾 {os.path.basename(path)}")
@@ -829,12 +918,12 @@ class MissionExecutorV3:
upload_index: int = 0) -> Optional[str]:
"""从机械臂摄像头拍照,直接上传到服务器(不保存本地)
upload_index: 从1开始,先正面后背面,由调用方维护
upload_index: 每台机器独立,从0开始
"""
try:
resp = requests.get(ARM_CAMERA_SNAPSHOT, timeout=10)
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
# 生成文件名(用于上传)
@@ -843,13 +932,14 @@ class MissionExecutorV3:
# 直接上传到服务器(不保存本地)
if qr_value:
self._log(f" 📷 拍照成功 {len(resp.content)} bytes → {fname}")
self._upload_photo_bytes(fname, resp.content, qr_value, upload_index)
else:
self._log(" ⚠️ 无二维码,跳过上传")
return fname # 返回文件名(用于日志)
except Exception as e:
logger.error(f"拍照异常: {e}")
self._log(f"拍照异常: {e}")
return None
def _upload_photo(self, filepath: str, serial_number: str, index: int) -> bool:
@@ -862,13 +952,41 @@ class MissionExecutorV3:
"""
try:
filename = os.path.basename(filepath)
headers = {
"Authorization": "Bearer eyJhbGciOiJIUzUxMiJ9.eyJ1c2VyX2lkIjoxLCJ1c2VyX2tleSI6ImZhNTNkZTZiLWE3NjYtNDZmNC05MDUyLTQ2MjUzZTAyNjdmNSIsInVzZXJuYW1lIjoiYWRtaW4ifQ.lC4vKThZo4aAOLsekm2kPgaEJRqRx-YDQWKfHFqxdPNESCKy57l3eIqaKTj2ZjAMaoYAwYlMrv5M1zAOJsO_PA"
}
headers = {"Authorization": ZHIJIAN_AUTH_TOKEN}
upload_url = UPLOAD_CONFIG["url"]
self._log(f" 📤 上传请求 → {upload_url} | serialNumber={serial_number} | index={index} | file={filename}")
with open(filepath, "rb") as f:
files = {"file": (filename, f, "image/jpeg")}
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:
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 _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
+85 -14
View File
@@ -29,19 +29,22 @@ class QRScanner:
def open(self) -> bool:
"""打开摄像头"""
try:
# 强制 V4L2 后端,获取标准彩色格式(与 test/server.py 一致)
# 强制 V4L2 后端
self._cap = cv2.VideoCapture(self.device_index, cv2.CAP_V4L2)
if self._cap.isOpened():
logger.info(f"摄像头 {self.device_index} 已打开 (V4L2)")
return True
else:
# fallback: 不指定后端
if not self._cap.isOpened():
self._cap = cv2.VideoCapture(self.device_index)
if self._cap.isOpened():
logger.info(f"摄像头 {self.device_index} 已打开 (默认后端)")
return True
if not self._cap.isOpened():
logger.error(f"无法打开摄像头 {self.device_index}")
return False
# 确保 OpenCV 做 BGR 转换(部分 V4L2 后端默认不做 YUYV→BGR 转换)
self._cap.set(cv2.CAP_PROP_CONVERT_RGB, 1)
# 设置分辨率(使用默认分辨率,不强制 MJPG)
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} 已打开,分辨率 {w}x{h}")
return True
except Exception as e:
logger.error(f"摄像头打开失败: {e}")
return False
@@ -51,14 +54,82 @@ class QRScanner:
self._cap.release()
self._cap = None
def read_frame(self) -> Optional[np.ndarray]:
"""读取一帧"""
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(self, timeout: float = 2.0) -> Optional[np.ndarray]:
"""读取一帧(带超时保护,避免 V4L2 select() 永久阻塞)"""
if not self._cap or not self._cap.isOpened():
return None
ret, frame = self._cap.read()
if not ret:
from concurrent.futures import ThreadPoolExecutor, TimeoutError as FuturesTimeout
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 frame
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
except Exception as e:
logger.error(f"read_frame 异常: {e}")
return None
finally:
pool.shutdown(wait=False)
def detect_qr(self, frame: np.ndarray) -> Optional[str]:
"""从图像帧中检测二维码"""
+408
View File
@@ -0,0 +1,408 @@
"""
机械臂服务端 - 机械臂端主程序
运行在 10.247.46.165 端口 5002 (TCP) + 5003 (视频流)
通过 TCP Socket 接收 AGV 发来的指令转发给 RoboFlow (ElephantRobot)
同时通过 ffmpeg 提供 HTTP 视频流
"""
import socket
import threading
import time
import logging
import os
import sys
import subprocess
import io
from PIL import Image
from flask import Flask, Response, jsonify
from werkzeug.serving import make_server
# 添加当前目录到路径
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(
level=logging.INFO,
format="%(asctime)s [%(levelname)s] %(name)s: %(message)s",
handlers=[
logging.StreamHandler(),
logging.FileHandler(LOG_FILE)
]
)
logger = logging.getLogger("arm_server")
# ========== Flask HTTP 服务器 - 视频流 (ffmpeg) ==========
arm_video_app = Flask(__name__)
ARM_CAMERA_INDEX = 0 # 机械臂端摄像头设备号
_ffmpeg_proc = None
_ffmpeg_thread = None
_ffmpeg_lock = threading.Lock()
_frame_cond = threading.Condition()
_latest_frame = None
_latest_frame_ts = 0.0
_stop_ffmpeg_reader = threading.Event()
_invalid_count = 0 # 连续无效帧计数
_MAX_INVALID = 30 # 连续 30 帧无效 → 重启 ffmpeg
_MAX_BUF_SIZE = 2 * 1024 * 1024 # 2MB buffer 上限
def _validate_jpeg(data):
"""验证 JPEG 数据是否有效,返回 True/False"""
try:
Image.open(io.BytesIO(data)).verify()
return True
except Exception:
return False
def _stop_ffmpeg():
"""停止 ffmpeg 采集进程和读帧线程。"""
global _ffmpeg_proc
_stop_ffmpeg_reader.set()
if _ffmpeg_proc and _ffmpeg_proc.poll() is None:
_ffmpeg_proc.terminate()
try:
_ffmpeg_proc.wait(timeout=2)
except subprocess.TimeoutExpired:
_ffmpeg_proc.kill()
_ffmpeg_proc = None
def _frame_reader():
"""从 ffmpeg 的连续 MJPEG 输出中解析 JPEG 帧,校验有效性并缓存最新一帧。
当摄像头 USB 掉线重连时ffmpeg 会从失效 fd 读取垃圾数据
产生假 JPEG 花屏这里通过 PIL 校验帧有效性
连续无效帧过多时自动重启 ffmpeg 恢复
"""
global _ffmpeg_proc, _latest_frame, _latest_frame_ts, _invalid_count
buf = b""
while not _stop_ffmpeg_reader.is_set():
proc = _ffmpeg_proc
if proc is None or proc.poll() is not None or proc.stdout is None:
time.sleep(0.1)
continue
chunk = proc.stdout.read(8192)
if not chunk:
if proc.poll() is not None:
break
time.sleep(0.02)
continue
buf += chunk
# 防止垃圾数据撑爆内存
if len(buf) > _MAX_BUF_SIZE:
logger.warning(f"frame buffer 超过 {_MAX_BUF_SIZE} 字节,丢弃并重启 ffmpeg")
buf = b""
_stop_ffmpeg()
continue
while True:
start = buf.find(b"\xff\xd8")
end = buf.find(b"\xff\xd9", start + 2) if start >= 0 else -1
if start < 0:
buf = buf[-2:]
break
if end < 0:
buf = buf[start:]
break
frame = buf[start:end + 2]
buf = buf[end + 2:]
# JPEG 校验:摄像头掉线时帧数据会损坏
if _validate_jpeg(frame):
with _frame_cond:
_latest_frame = frame
_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 # 跳出循环让 _ensure_ffmpeg 重建
def _ensure_ffmpeg():
"""确保 ffmpeg 进程在运行,自动重启崩溃的进程"""
global _ffmpeg_proc, _ffmpeg_thread, _invalid_count
with _ffmpeg_lock:
if _ffmpeg_proc is not None and _ffmpeg_proc.poll() is None:
return
_stop_ffmpeg_reader.set()
if _ffmpeg_proc and _ffmpeg_proc.poll() is None:
_ffmpeg_proc.terminate()
_stop_ffmpeg_reader.clear()
_invalid_count = 0 # 重置错误计数
logger.info(f"启动 ffmpeg 视频流 (Video{ARM_CAMERA_INDEX})")
_ffmpeg_proc = subprocess.Popen(
[
"ffmpeg",
"-f", "v4l2",
"-input_format", "mjpeg",
"-framerate", "8",
"-video_size", "640x480",
"-i", f"/dev/video{ARM_CAMERA_INDEX}",
"-fflags", "nobuffer",
"-analyzeduration", "0",
"-f", "mjpeg",
"-"
],
stdout=subprocess.PIPE,
stderr=subprocess.DEVNULL,
)
_ffmpeg_thread = threading.Thread(target=_frame_reader, daemon=True)
_ffmpeg_thread.start()
def _get_latest_frame(timeout: float = 3.0):
"""返回缓存的最新 JPEG 帧;必要时等待首帧。"""
_ensure_ffmpeg()
deadline = time.time() + timeout
with _frame_cond:
while _latest_frame is None and time.time() < deadline:
_frame_cond.wait(timeout=0.2)
return _latest_frame
@arm_video_app.route("/api/camera/preview")
def arm_camera_preview():
"""机械臂摄像头 MJPEG 流,共用后台 ffmpeg 采集进程。"""
_ensure_ffmpeg()
def generate():
last_ts = 0.0
try:
while True:
frame = _get_latest_frame(timeout=3.0)
if frame is None:
logger.warning("等待摄像头帧超时,重启 ffmpeg")
_stop_ffmpeg()
continue
with _frame_cond:
if _latest_frame_ts <= last_ts:
_frame_cond.wait(timeout=1.0)
frame = _latest_frame
last_ts = _latest_frame_ts
if frame:
yield (b"--frame\r\nContent-Type: image/jpeg\r\n\r\n" + frame + b"\r\n")
except Exception as e:
logger.error(f"视频流异常: {e}")
finally:
logger.info("视频流连接关闭")
return Response(generate(), mimetype="multipart/x-mixed-replace; boundary=frame")
@arm_video_app.route("/api/camera/status")
def arm_camera_status():
"""摄像头状态"""
global _ffmpeg_proc
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,
"invalid_count": _invalid_count
})
@arm_video_app.route("/api/camera/restart", methods=["POST"])
def arm_camera_restart():
"""重启视频流"""
global _latest_frame, _latest_frame_ts, _invalid_count
_stop_ffmpeg()
with _frame_cond:
_latest_frame = None
_latest_frame_ts = 0.0
_invalid_count = 0
_ensure_ffmpeg()
return jsonify({"ok": True})
@arm_video_app.route("/api/camera/snapshot")
def arm_camera_snapshot():
"""机械臂摄像头单帧 JPEG,从常驻视频流缓存读取最新帧。"""
frame = _get_latest_frame(timeout=3.0)
if frame:
r = Response(frame, mimetype="image/jpeg")
r.headers["Cache-Control"] = "no-cache, no-store, must-revalidate, max-age=0"
r.headers["Pragma"] = "no-cache"
r.headers["Expires"] = "0"
return r
logger.warning("snapshot failed: no cached frame")
return "", 500
# ========== TCP 服务器 - 接收 AGV 指令 ==========
class AGVCommandServer:
"""TCP 服务器,接收 AGV 发来的指令,通过 ElephantRobot 转发给 RoboFlow"""
def __init__(self, elephant, host: str = "0.0.0.0", port: int = 5002):
self.host = host
self.port = port
self._sock: socket.socket = None
self._running = False
# 直接从外部注入已激活的 ElephantRobot 实例
if elephant is None:
logger.warning("ElephantRobot 实例为空,命令将返回错误")
self._el = elephant
def start(self):
self._sock = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
self._sock.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1)
self._sock.bind((self.host, self.port))
self._sock.listen(5)
self._running = True
logger.info(f"=" * 50)
logger.info(f"机械臂服务端已启动,监听 {self.host}:{self.port}")
logger.info(f"等待 AGV 连接...")
logger.info(f"=" * 50)
while self._running:
try:
self._sock.settimeout(1.0)
try:
client_sock, addr = self._sock.accept()
logger.info(f"AGV 已连接: {addr}")
threading.Thread(target=self._handle_client, args=(client_sock,), daemon=True).start()
except socket.timeout:
continue
except Exception as e:
if self._running:
logger.error(f"服务器异常: {e}")
break
def _handle_client(self, client_sock: socket.socket):
try:
client_sock.settimeout(30)
buffer = ""
while self._running:
try:
data = client_sock.recv(4096)
if not data:
break
buffer += data.decode("utf-8")
while "\n" in buffer:
line, buffer = buffer.split("\n", 1)
line = line.strip()
if not line:
continue
response = self._execute_command(line)
client_sock.sendall((response + "\n").encode("utf-8"))
logger.info(f"CMD: {line}{response}")
except socket.timeout:
continue
except Exception as e:
logger.error(f"客户端处理异常: {e}")
finally:
client_sock.close()
logger.info("AGV 客户端已断开")
def _execute_command(self, cmd: str) -> str:
"""通过 ElephantRobot.send_command 转发给 RoboFlow"""
if self._el is None:
return "ERROR: Robot not initialized"
try:
return self._el.send_command(cmd)
except Exception as e:
return f"ERROR: {e}"
def stop(self):
self._running = False
if self._sock:
try:
self._sock.close()
except:
pass
logger.info("机械臂服务端已停止")
# ========== 入口 ==========
_elephant = None # 全局 ElephantRobot 实例
def power_on_arm(max_retries: int = 5) -> bool:
"""通过 ElephantRobot 给机械臂上电并激活(带重试),返回 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():
import signal
# 先通过 ElephantRobot 给机械臂上电并激活
power_on_arm()
# 将全局 _elephant 传给指令服务器
server = AGVCommandServer(_elephant, port=5002)
# 启动 Flask 视频流服务(端口 5003)
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.start()
logger.info("机械臂视频流服务已启动: http://0.0.0.0:5003")
def signal_handler(sig, frame):
logger.info("收到停止信号...")
global _ffmpeg_proc, _elephant
if _ffmpeg_proc:
_ffmpeg_proc.terminate()
server.stop()
arm_server_http.shutdown()
if _elephant:
try:
_elephant.stop_client()
except:
pass
sys.exit(0)
signal.signal(signal.SIGINT, signal_handler)
signal.signal(signal.SIGTERM, signal_handler)
server.start()
if __name__ == "__main__":
main()
+56 -18
View File
@@ -15,7 +15,9 @@ from flask import Flask, Response, jsonify
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(
@@ -23,7 +25,7 @@ logging.basicConfig(
format="%(asctime)s [%(levelname)s] %(name)s: %(message)s",
handlers=[
logging.StreamHandler(),
logging.FileHandler(os.path.expanduser("~/work/arm_server/server.log"))
logging.FileHandler(LOG_FILE)
]
)
logger = logging.getLogger("arm_server")
@@ -105,11 +107,11 @@ def _ensure_ffmpeg():
"ffmpeg",
"-f", "v4l2",
"-input_format", "mjpeg",
"-framerate", "15",
"-video_size", "640x480",
"-framerate", "12",
"-video_size", "1280x720",
"-i", f"/dev/video{ARM_CAMERA_INDEX}",
"-vf", "rotate=PI",
"-q:v", "8",
"-q:v", "4",
"-f", "mjpeg",
"-"
],
@@ -254,19 +256,7 @@ class AGVCommandServer:
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}")
logger.info("RoboFlow 连接成功(上电由 power_on_arm() 完成)")
else:
logger.warning("RoboFlow 连接失败,服务将以 limited 模式运行")
@@ -342,13 +332,61 @@ class AGVCommandServer:
# ========== 入口 ==========
import time
def power_on_arm(max_retries: int = 5) -> bool:
"""通过 ElephantRobot 给机械臂上电并激活(带重试)"""
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("✅ 机械臂上电+激活 全部完成")
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():
import signal
# 先通过 ElephantRobot 给机械臂上电并激活
power_on_arm()
server = AGVCommandServer(port=5002)
# 启动 Flask 视频流服务(端口 5003)
from werkzeug.serving import make_server
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.start()
logger.info("机械臂视频流服务已启动: http://0.0.0.0:5003")
+19
View File
@@ -0,0 +1,19 @@
[Unit]
Description=Arm Server (TCP 5002 + Camera 5003)
After=network-online.target
Wants=network-online.target
[Service]
Type=simple
User=pi
WorkingDirectory=/home/pi/work/smart-inspection/arm_server
Environment=PATH=/home/pi/.local/bin:/usr/local/bin:/usr/bin:/bin
ExecStartPre=/bin/sleep 5
ExecStart=/usr/bin/env uv run --locked python arm_server.py
Restart=on-failure
RestartSec=5
StandardOutput=append:/home/pi/work/smart-inspection/arm_server/stdout.log
StandardError=append:/home/pi/work/smart-inspection/arm_server/stderr.log
[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
# 启动机械臂服务端
set -e
cd ~/work/arm_server
PYTHON_BIN="${PYTHON_BIN:-/usr/bin/python3}"
SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)"
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
echo "Flask 未安装,正在安装 requirements.txt..."
"$PYTHON_BIN" -m pip install --user -r requirements.txt
fi
exec "$PYTHON_BIN" arm_server.py
cd "$ARM_SERVER_DIR"
exec uv run --locked python arm_server.py
+3
View File
@@ -0,0 +1,3 @@
{
"extends": ["next/core-web-vitals", "next/typescript"]
}
+36
View File
@@ -0,0 +1,36 @@
# See https://help.github.com/articles/ignoring-files/ for more about ignoring files.
# dependencies
/node_modules
/.pnp
.pnp.js
.yarn/install-state.gz
# testing
/coverage
# next.js
/.next/
/out/
# production
/build
# misc
.DS_Store
*.pem
# debug
npm-debug.log*
yarn-debug.log*
yarn-error.log*
# local env files
.env*.local
# vercel
.vercel
# typescript
*.tsbuildinfo
next-env.d.ts
+36
View File
@@ -0,0 +1,36 @@
This is a [Next.js](https://nextjs.org) project bootstrapped with [`create-next-app`](https://nextjs.org/docs/app/api-reference/cli/create-next-app).
## Getting Started
First, run the development server:
```bash
npm run dev
# or
yarn dev
# or
pnpm dev
# or
bun dev
```
Open [http://localhost:3000](http://localhost:3000) with your browser to see the result.
You can start editing the page by modifying `app/page.tsx`. The page auto-updates as you edit the file.
This project uses [`next/font`](https://nextjs.org/docs/app/building-your-application/optimizing/fonts) to automatically optimize and load [Geist](https://vercel.com/font), a new font family for Vercel.
## Learn More
To learn more about Next.js, take a look at the following resources:
- [Next.js Documentation](https://nextjs.org/docs) - learn about Next.js features and API.
- [Learn Next.js](https://nextjs.org/learn) - an interactive Next.js tutorial.
You can check out [the Next.js GitHub repository](https://github.com/vercel/next.js) - your feedback and contributions are welcome!
## Deploy on Vercel
The easiest way to deploy your Next.js app is to use the [Vercel Platform](https://vercel.com/new?utm_medium=default-template&filter=next.js&utm_source=create-next-app&utm_campaign=create-next-app-readme) from the creators of Next.js.
Check out our [Next.js deployment documentation](https://nextjs.org/docs/app/building-your-application/deploying) for more details.
+4
View File
@@ -0,0 +1,4 @@
/** @type {import('next').NextConfig} */
const nextConfig = {};
export default nextConfig;
File diff suppressed because it is too large Load Diff
+32
View File
@@ -0,0 +1,32 @@
{
"name": "customs-tablet-frontend",
"version": "0.1.0",
"private": true,
"scripts": {
"dev": "next dev",
"build": "next build",
"start": "next start",
"lint": "next lint"
},
"dependencies": {
"@ant-design/icons": "^6.2.5",
"@ant-design/nextjs-registry": "^1.3.0",
"antd": "^6.4.4",
"dayjs": "^1.11.21",
"html5-qrcode": "^2.3.8",
"next": "14.2.35",
"photoswipe": "^5.4.4",
"react": "^18",
"react-dom": "^18",
"react-photoswipe-gallery": "^4.1.2",
"zustand": "^5.0.14"
},
"devDependencies": {
"@types/node": "^20",
"@types/react": "^18",
"@types/react-dom": "^18",
"eslint": "^8",
"eslint-config-next": "14.2.35",
"typescript": "^5"
}
}
@@ -0,0 +1,175 @@
'use client';
import React, { useEffect, useState } from 'react';
import { Alert, Card, Table, Form, Button, DatePicker, Select, Space, Row, Col, Input } from 'antd';
import { SearchOutlined, PlayCircleOutlined } from '@ant-design/icons';
import { useRouter } from 'next/navigation';
import { Breadcrumb } from '../../components/Breadcrumb';
import { MockApi } from '../../services/mockApi';
import { CustomsDeclaration } from '../../types';
import { StatusBadge } from '../../components/StatusBadge';
import { useAppStore } from '../../store/useAppStore';
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 [errorMessage, setErrorMessage] = useState('');
const { setSelectedCustoms } = useAppStore();
useEffect(() => {
let isMounted = true;
const loadCustomsList = async () => {
try {
setLoading(true);
setErrorMessage('');
const res = await MockApi.getCustomsList();
if (!isMounted) return;
setData(res);
setFilteredData(res);
} catch {
if (!isMounted) return;
setErrorMessage('报关单列表加载失败,请稍后重试');
} finally {
if (isMounted) {
setLoading(false);
}
}
};
loadCustomsList();
return () => {
isMounted = false;
};
}, []);
const handleStartInspection = (record: CustomsDeclaration) => {
setSelectedCustoms(record);
router.push(`/inspection?customsId=${encodeURIComponent(record.customsId)}`);
};
const [form] = Form.useForm();
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.customsId.toLowerCase().includes(keyword);
const createdAt = new Date(item.createdAt);
const matchesDateRange = !dateRange?.[0] || !dateRange?.[1]
|| (createdAt >= dateRange[0].startOf('day').toDate() && createdAt <= dateRange[1].endOf('day').toDate());
return matchesStatus && matchesKeyword && matchesDateRange;
});
setFilteredData(nextData);
};
const handleReset = () => {
form.resetFields();
setFilteredData(data);
};
const expandedRowRender = (record: CustomsDeclaration) => {
const columns = [
{ 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' },
];
return <Table columns={columns} dataSource={record.items} pagination={false} size="small" rowKey="inventoryCode" />;
};
return (
<div>
<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 }}>
<Select.Option value="all"></Select.Option>
<Select.Option value="pending"></Select.Option>
<Select.Option value="inspecting"></Select.Option>
<Select.Option value="released"></Select.Option>
<Select.Option value="abnormal"></Select.Option>
</Select>
</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"
expandable={{ expandedRowRender }}
>
<Table.Column title="报关单号" dataIndex="customsId" key="customsId" render={(text: string) => <b>{text}</b>} />
<Table.Column title="状态" dataIndex="status" key="status" render={(status: string) => <StatusBadge status={status as 'pending' | 'inspecting' | 'released' | 'abnormal'} />} />
<Table.Column title="机器总数" dataIndex="machineCount" key="machineCount" render={(count: number) => `${count}`} />
<Table.Column title="创建时间" dataIndex="createdAt" key="createdAt" />
<Table.Column
title="操作"
key="action"
render={(_, record: CustomsDeclaration) => (
<Space size="middle">
{record.status === 'pending' || record.status === 'inspecting' ? (
<Button type="primary" icon={<PlayCircleOutlined />} onClick={() => handleStartInspection(record)}>
</Button>
) : (
<Button type="link"></Button>
)}
</Space>
)}
/>
</Table>
</Card>
</div>
);
}
Binary file not shown.

After

Width:  |  Height:  |  Size: 25 KiB

Binary file not shown.
@@ -0,0 +1,52 @@
/* Ant Design 自带主题 Token,移除不必要的自定义变量 */
: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;
}
@@ -0,0 +1,412 @@
'use client';
import React, { Suspense, useEffect, useState, useRef } from 'react';
import { Alert, Row, Col, Card, Button, Progress, List, Typography, Space, Modal, Input, Empty, Badge, Spin, Flex, Select, Segmented, theme, Divider, Timeline } from 'antd';
import {
PlayCircleOutlined,
PauseCircleOutlined,
StopOutlined,
ReloadOutlined,
CaretRightOutlined,
PauseCircleFilled,
VideoCameraOutlined
} from '@ant-design/icons';
import { Breadcrumb } from '../../components/Breadcrumb';
import { MockApi } from '../../services/mockApi';
import { CustomsDeclaration, InspectionItem } from '../../types';
import { useAppStore } from '../../store/useAppStore';
import { useRouter, useSearchParams } from 'next/navigation';
const { Text } = Typography;
const { TextArea } = Input;
type InspectionStatus = 'idle' | 'running' | 'paused' | 'completed';
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 } = useAppStore();
const [currentCustoms, setCurrentCustoms] = useState<CustomsDeclaration | null>(null);
const [loadingCustoms, setLoadingCustoms] = useState(true);
const [errorMessage, setErrorMessage] = useState('');
const [status, setStatus] = useState<InspectionStatus>('idle');
const [logs, setLogs] = useState<{time: string, msg: string, type: 'info'|'warning'|'success'}[]>([]);
const [progressData, setProgressData] = useState<ProgressItem[]>([]);
const [isPauseModalVisible, setIsPauseModalVisible] = useState(false);
const [pauseReason, setPauseReason] = useState('');
const [customsList, setCustomsList] = useState<CustomsDeclaration[]>([]);
const [loadingList, setLoadingList] = useState(false);
const [currentView, setCurrentView] = useState<string>('摄像头1');
const { token } = theme.useToken();
const logsEndRef = useRef<HTMLDivElement>(null);
useEffect(() => {
setLoadingList(true);
MockApi.getCustomsList().then(list => {
setCustomsList(list);
setLoadingList(false);
});
}, []);
useEffect(() => {
let isMounted = true;
const loadInspectionCustoms = async () => {
setLoadingCustoms(true);
setErrorMessage('');
try {
if (customsId) {
const cachedCustoms = selectedCustoms?.customsId === customsId ? selectedCustoms : null;
const customs = cachedCustoms ?? await MockApi.getCustomsById(customsId);
if (!isMounted) return;
if (!customs) {
setCurrentCustoms(null);
setErrorMessage(`未找到报关单 ${customsId}`);
return;
}
setCurrentCustoms(customs);
setSelectedCustoms(customs);
return;
}
setCurrentCustoms(selectedCustoms);
} catch {
if (!isMounted) return;
setCurrentCustoms(null);
setErrorMessage('查验任务加载失败,请稍后重试');
} finally {
if (isMounted) {
setLoadingCustoms(false);
}
}
};
loadInspectionCustoms();
return () => {
isMounted = false;
};
}, [customsId, selectedCustoms, setSelectedCustoms]);
useEffect(() => {
if (!currentCustoms) {
setProgressData([]);
setStatus('idle');
return;
}
setProgressData(currentCustoms.items.map(item => ({
...item,
currentInspected: item.inspected
})));
setStatus(currentCustoms.status === 'inspecting' ? 'running' : 'idle');
setLogs([]);
}, [currentCustoms]);
// 模拟查验过程
useEffect(() => {
let timer: NodeJS.Timeout;
if (status === 'running') {
timer = setInterval(() => {
setProgressData(prev => {
let allDone = true;
const next = prev.map(item => {
if (item.currentInspected < item.quantify) {
allDone = false;
// 随机增加进度
if (Math.random() > 0.5) {
const newInspected = Math.min(item.currentInspected + 1, item.quantify);
if (newInspected > item.currentInspected) {
addLog(`料号 ${item.inventoryCode} (${item.inventoryName}) 核销 +1`, 'info');
}
return { ...item, currentInspected: newInspected };
}
}
return item;
});
if (allDone) {
setStatus('completed');
addLog('全部机器核销完成', 'success');
}
return next;
});
}, 3000); // 每 3 秒更新一次模拟数据
}
return () => clearInterval(timer);
}, [status]);
// 自动滚动到最新日志
useEffect(() => {
if (logsEndRef.current) {
logsEndRef.current.scrollIntoView({ behavior: 'smooth' });
}
}, [logs]);
const addLog = (msg: string, type: 'info'|'warning'|'success' = 'info') => {
setLogs(prev => [...prev, { time: new Date().toLocaleTimeString(), msg, type }]);
};
const handleStart = () => {
setStatus('running');
addLog('开始自动化查验作业', 'info');
};
const handlePause = () => {
setIsPauseModalVisible(true);
};
const confirmPause = () => {
setStatus('paused');
addLog(`查验已暂停。原因:${pauseReason || '未填写'}`, 'warning');
setIsPauseModalVisible(false);
setPauseReason('');
};
const handleEnd = () => {
Modal.confirm({
title: '确认结束查验?',
content: '结束查验后无法继续当前任务。',
onOk: () => {
setStatus('completed');
addLog('用户手动结束查验', 'success');
}
});
};
const calculateTotalProgress = () => {
if (!progressData.length) return 0;
const total = progressData.reduce((acc, curr) => acc + curr.quantify, 0);
const inspected = progressData.reduce((acc, curr) => acc + curr.currentInspected, 0);
return Math.round((inspected / total) * 100);
};
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)' }}>
<Breadcrumb />
<Row gutter={24} style={{ flex: 1, minHeight: 0, margin: '0 24px 24px 24px' }}>
{/* 左侧:AGV 及监控画面 */}
<Col span={14}>
<Card
title={
<Flex justify="space-between" align="center">
<Space>
<VideoCameraOutlined style={{ color: token.colorPrimary }} />
<span>AGV </span>
</Space>
<Space>
<Badge status="processing" text="设备在线" />
</Space>
</Flex>
}
styles={{ body: { padding: 0, display: 'flex', flexDirection: 'column', flex: 1 } }}
style={{ height: '100%', display: 'flex', flexDirection: 'column', borderRadius: token.borderRadiusLG, overflow: 'hidden' }}
bordered={false}
>
<div
style={{
position: 'relative',
display: 'flex',
flex: 1,
alignItems: 'center',
justifyContent: 'center',
background: '#000000'
}}
>
{status === 'running' ? (
<Flex vertical align="center" gap={16} style={{ color: '#ffffff' }}>
<CaretRightOutlined style={{ fontSize: 48, color: token.colorPrimary, opacity: 0.8 }} />
<Text style={{ color: '#fff' }}>...</Text>
<div style={{ padding: '8px 16px', border: `1px dashed ${token.colorPrimary}`, borderRadius: token.borderRadius }}>
<span style={{ color: token.colorPrimary }}>AI </span>
</div>
</Flex>
) : (
<Flex vertical align="center" gap={16} style={{ color: token.colorTextDescription }}>
<PauseCircleFilled style={{ fontSize: 48 }} />
<Text type="secondary"></Text>
</Flex>
)}
</div>
<div style={{ padding: '16px 24px', borderTop: `1px solid ${token.colorBorderSecondary}`, background: token.colorFillAlter }}>
<Flex align="center" gap="middle">
<Text strong>:</Text>
<Segmented
options={['摄像头1', '摄像头2', '摄像头3', '摄像头4', '摄像头5']}
value={currentView}
onChange={setCurrentView}
/>
</Flex>
</div>
</Card>
</Col>
{/* 右侧:查验控制面板 */}
<Col span={10}>
<Card
title={
<Flex justify="space-between" align="center">
<span></span>
<Select
showSearch
placeholder="搜索并选择报关单..."
style={{ width: 240 }}
loading={loadingList}
optionFilterProp="label"
filterOption={(input, option) =>
(option?.label as string ?? '').toLowerCase().includes(input.toLowerCase())
}
options={customsList.map(item => ({
value: item.customsId,
label: `${item.customsId} - ${item.status === 'pending' ? '待查验' : item.status === 'inspecting' ? '查验中' : '已放行'}`
}))}
value={currentCustoms?.customsId || undefined}
onChange={(value) => {
router.push(`/inspection?customsId=${value}`);
}}
/>
</Flex>
}
styles={{ body: { overflow: 'hidden', padding: '24px', display: 'flex', flexDirection: 'column', flex: 1 } }}
style={{ height: '100%', display: 'flex', flexDirection: 'column', borderRadius: token.borderRadiusLG }}
bordered={false}
>
{!currentCustoms ? (
<Flex vertical align="center" justify="center" style={{ flex: 1 }}>
{errorMessage && (
<Alert
type="error"
message={errorMessage}
showIcon
style={{ marginBottom: 24, width: '100%' }}
/>
)}
<Empty description="请在右上角选择要查验的报关单" />
</Flex>
) : (
<>
<Flex justify="center" gap="middle" style={{ marginBottom: 32 }}>
{status === 'idle' || status === 'paused' ? (
<Button type="primary" size="large" icon={<PlayCircleOutlined />} onClick={handleStart} style={{ width: 140 }}>
{status === 'idle' ? '开始查验' : '继续查验'}
</Button>
) : status === 'running' ? (
<Button type="primary" danger size="large" icon={<PauseCircleOutlined />} onClick={handlePause} style={{ width: 140 }}>
</Button>
) : null}
<Button size="large" icon={<ReloadOutlined />} disabled={status === 'completed'}></Button>
<Button danger size="large" icon={<StopOutlined />} onClick={handleEnd} disabled={status === 'completed' || status === 'idle'}></Button>
</Flex>
<Divider titlePlacement="start" plain style={{ margin: '0 0 16px 0' }}><Text strong></Text></Divider>
<div style={{ marginBottom: 16, padding: '0 8px' }}>
<Progress percent={calculateTotalProgress()} status={status === 'completed' ? 'success' : 'active'} strokeWidth={10} />
</div>
<div style={{ flex: '0 1 35%', marginBottom: 24, overflowY: 'auto', paddingRight: 8 }}>
<List
size="small"
dataSource={progressData}
renderItem={item => (
<List.Item style={{ padding: '12px 8px', borderBottom: `1px solid ${token.colorBorderSecondary}` }}>
<div style={{ width: '100%' }}>
<Flex justify="space-between" align="center" style={{ marginBottom: 8 }}>
<Text strong>{item.inventoryName}</Text>
<Space>
<Text type="secondary" style={{ fontSize: 13 }}>{item.inventoryCode}</Text>
<Badge count={`${item.currentInspected} / ${item.quantify}`} style={{ backgroundColor: item.currentInspected === item.quantify ? token.colorSuccess : token.colorPrimary }} />
</Space>
</Flex>
<Progress percent={Math.round((item.currentInspected / item.quantify) * 100)} showInfo={false} size="small" />
</div>
</List.Item>
)}
/>
</div>
<Divider titlePlacement="start" plain style={{ margin: '0 0 16px 0' }}><Text strong></Text></Divider>
<div style={{
flex: 1,
overflowY: 'auto',
border: `1px solid ${token.colorBorderSecondary}`,
borderRadius: token.borderRadiusLG,
background: token.colorFillQuaternary,
padding: 16
}}>
{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>{item.msg}</Text>
</Space>
),
}))}
/>
) : (
<Empty image={Empty.PRESENTED_IMAGE_SIMPLE} description="暂无日志" style={{ margin: '20px 0' }} />
)}
<div ref={logsEndRef} />
</div>
</>
)}
</Card>
</Col>
</Row>
<Modal
title="暂停查验"
open={isPauseModalVisible}
onOk={confirmPause}
onCancel={() => setIsPauseModalVisible(false)}
okText="确认暂停"
cancelText="取消"
okButtonProps={{ danger: true }}
>
<Flex vertical gap={16} style={{ paddingTop: 16 }}>
<Text></Text>
<TextArea
rows={4}
placeholder="请输入暂停原因(可选)..."
value={pauseReason}
onChange={e => setPauseReason(e.target.value)}
/>
</Flex>
</Modal>
</div>
);
}
@@ -0,0 +1,54 @@
import type { Metadata } from "next";
import { Inter } from "next/font/google";
import "./globals.css";
import { AntdRegistry } from '@ant-design/nextjs-registry';
import { ConfigProvider, App } from 'antd';
import { TopHeader } from '../components/TopHeader';
const inter = Inter({ subsets: ["latin"] });
export const metadata: Metadata = {
title: "海关平板前端系统",
description: "海关查验系统平板端原型",
};
export default function RootLayout({
children,
}: Readonly<{
children: React.ReactNode;
}>) {
return (
<html lang="zh-CN">
<body className={`${inter.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,167 @@
'use client';
import React, { useEffect, useState } from 'react';
import { Alert, Card, Row, Col, Typography, Space, Button, Tabs, Table, Image as AntImage, Empty, Spin, Flex } from 'antd';
import { ArrowLeftOutlined } from '@ant-design/icons';
import { useRouter } from 'next/navigation';
import { Breadcrumb } from '../../../components/Breadcrumb';
import { MockApi } from '../../../services/mockApi';
import { MachineDetail, ImageItem } from '../../../types';
import { StatusBadge } from '../../../components/StatusBadge';
const { Text } = Typography;
export default function MachineDetailPage({ params }: { params: { serialNumber: string } }) {
const router = useRouter();
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 MockApi.getMachineDetail(params.serialNumber);
if (!isMounted) return;
setMachine(data);
} catch {
if (!isMounted) return;
setMachine(null);
setErrorMessage('机器详情加载失败,请稍后重试');
} finally {
if (isMounted) {
setLoading(false);
}
}
};
loadMachineDetail();
return () => {
isMounted = false;
};
}, [params.serialNumber]);
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 renderImageGroup = (images: ImageItem[]) => {
if (!images || images.length === 0) return <Empty description="暂无图片" />;
return (
<AntImage.PreviewGroup>
<Space size={[16, 16]} wrap>
{images.map((img) => (
<Flex
key={img.id}
vertical
style={{
position: 'relative',
width: 120,
gap: 4
}}
>
<div style={{ width: '100%', aspectRatio: '4/3', overflow: 'hidden', borderRadius: 8, background: '#f0f0f0' }}>
<AntImage
src={img.url}
alt={img.name}
width="100%"
height="100%"
style={{ objectFit: 'cover' }}
preview={{
src: img.url,
}}
/>
</div>
<Text style={{ fontSize: 12, textAlign: 'center' }}>{img.name}</Text>
<Text type="secondary" style={{ fontSize: 11, textAlign: 'center' }}>{img.createdAt}</Text>
</Flex>
))}
</Space>
</AntImage.PreviewGroup>
);
};
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>
<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">{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}
locale={{ emptyText: <Empty description="暂无查验记录" /> }}
>
<Table.Column title="查验时间" dataIndex="time" />
<Table.Column title="操作人" dataIndex="operator" />
<Table.Column title="结果" dataIndex="result" />
<Table.Column title="备注" dataIndex="remark" />
</Table>
</Card>
</div>
);
}
@@ -0,0 +1,175 @@
'use client';
import React, { useState, useEffect } from 'react';
import { Card, Row, Col, Input, Button, Table, Typography, Space, Modal, Upload, Flex } from 'antd';
import { CameraOutlined, BarcodeOutlined, FileImageOutlined, SearchOutlined, BulbOutlined } from '@ant-design/icons';
import { useRouter } from 'next/navigation';
import { Breadcrumb } from '../../components/Breadcrumb';
const { Title, Text } = Typography;
export default function MachineQueryPage() {
const router = useRouter();
const [serialNumber, setSerialNumber] = useState('');
const [isScanModalVisible, setIsScanModalVisible] = useState(false);
const [recentQueries, setRecentQueries] = useState<{serialNumber: string, name: string, time: string}[]>([]);
useEffect(() => {
// Load recent queries from localStorage or mock
const saved = localStorage.getItem('recent_queries');
if (saved) {
setRecentQueries(JSON.parse(saved));
} else {
setRecentQueries([
{ serialNumber: 'BG042110276', name: '打印机型号A', time: '06-19 14:30' },
{ serialNumber: 'BG042110285', name: '扫描仪型号B', time: '06-19 10:15' }
]);
}
}, []);
const handleSearch = (sn: string) => {
if (!sn) return;
// Save to recent queries
const newQuery = { serialNumber: sn, name: '未知设备 (模拟)', time: new Date().toLocaleString() };
const updated = [newQuery, ...recentQueries.filter(q => q.serialNumber !== sn)].slice(0, 10);
setRecentQueries(updated);
localStorage.setItem('recent_queries', JSON.stringify(updated));
router.push(`/machines/${sn}`);
};
return (
<div>
<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(--color-primary)' }} />
<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(--color-primary)' }} />
<Title level={4} style={{ margin: 0 }}></Title>
<Text type="secondary"></Text>
<Space.Compact style={{ width: '80%' }}>
<Input
placeholder="请输入序列号..."
size="large"
value={serialNumber}
onChange={(e) => setSerialNumber(e.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 }}>
<Upload.Dragger
accept="image/*"
showUploadList={false}
customRequest={({ onSuccess }) => {
setTimeout(() => {
onSuccess?.('ok');
handleSearch('BG042110276'); // 模拟识别成功
}, 1000);
}}
>
<p className="ant-upload-drag-icon">
<FileImageOutlined style={{ fontSize: 48 }} />
</p>
<p className="ant-upload-text"></p>
<p className="ant-upload-hint"> JPG / PNG / BMP</p>
</Upload.Dragger>
</Card>
<Card title="最近查询记录">
<Table
dataSource={recentQueries}
rowKey="serialNumber"
pagination={false}
size="middle"
>
<Table.Column title="序列号" dataIndex="serialNumber" />
<Table.Column title="机器名称" dataIndex="name" />
<Table.Column title="查询时间" dataIndex="time" />
<Table.Column
title="操作"
render={(_, record: {serialNumber: string}) => (
<Button type="link" onClick={() => handleSearch(record.serialNumber)}></Button>
)}
/>
</Table>
</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>
<Button
onClick={() => {
setIsScanModalVisible(false);
handleSearch('BG042110276');
}}
>
(BG042110276)
</Button>
</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 style={{ position: 'absolute', top: -2, left: -2, width: 20, height: 20, borderTop: '4px solid #1890ff', borderLeft: '4px solid #1890ff' }}></div>
<div style={{ position: 'absolute', top: -2, right: -2, width: 20, height: 20, borderTop: '4px solid #1890ff', borderRight: '4px solid #1890ff' }}></div>
<div style={{ position: 'absolute', bottom: -2, left: -2, width: 20, height: 20, borderBottom: '4px solid #1890ff', borderLeft: '4px solid #1890ff' }}></div>
<div style={{ position: 'absolute', bottom: -2, right: -2, width: 20, height: 20, borderBottom: '4px solid #1890ff', borderRight: '4px solid #1890ff' }}></div>
</div>
</div>
<div style={{ marginTop: 16, textAlign: 'center', color: '#666666' }}>
<BulbOutlined />
</div>
</Modal>
</div>
);
}
+227
View File
@@ -0,0 +1,227 @@
'use client';
import React, { useEffect, useState } from 'react';
import { Alert, Card, Row, Col, List, Button, Table, Statistic, Flex } from 'antd';
import {
FileTextOutlined,
CheckCircleOutlined,
SyncOutlined,
WarningOutlined,
ScanOutlined,
SearchOutlined,
VideoCameraOutlined,
RightOutlined,
ClockCircleOutlined,
ProfileOutlined
} from '@ant-design/icons';
import { useRouter } from 'next/navigation';
import { MockApi } from '../services/mockApi';
import { CustomsStats, ActivityItem, CustomsDeclaration } 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('');
useEffect(() => {
let isMounted = true;
const loadData = async () => {
try {
setErrorMessage('');
const [statsData, actData, customsData] = await Promise.all([
MockApi.getCustomsStats(),
MockApi.getRecentActivities(),
MockApi.getPendingCustoms()
]);
if (!isMounted) return;
setStats(statsData);
setActivities(actData);
setPendingCustoms(customsData);
} catch {
if (!isMounted) return;
setErrorMessage('首页数据加载失败,请稍后重试');
}
};
loadData();
return () => {
isMounted = false;
};
}, []);
const inspectingCustoms = pendingCustoms.find(item => item.status === 'inspecting');
const goToInspection = () => {
if (inspectingCustoms) {
router.push(`/inspection?customsId=${encodeURIComponent(inspectingCustoms.customsId)}`);
return;
}
router.push('/customs');
};
const statCards = [
{
title: '待查验',
value: stats?.pendingCount || 0,
icon: <FileTextOutlined />,
suffix: '份报关单',
valueStyle: { color: '#1890ff' },
onClick: () => router.push('/customs')
},
{
title: '今日已放行',
value: stats?.releasedToday || 0,
icon: <CheckCircleOutlined />,
suffix: '份报关单',
valueStyle: { color: '#52c41a' }
},
{
title: '查验进行中',
value: stats?.inspectingCount || 0,
icon: <SyncOutlined spin />,
suffix: '个任务',
valueStyle: { color: '#faad14' },
onClick: goToInspection
},
{
title: '异常',
value: stats?.abnormalCount || 0,
icon: <WarningOutlined />,
suffix: '个异常',
valueStyle: { color: '#ff4d4f' }
},
];
const quickActions = [
{ title: '扫码查询机器', desc: '使用平板摄像头扫描设备二维码', icon: <ScanOutlined />, onClick: () => router.push('/machines') },
{ title: '序列号查询机器', desc: '手动输入序列号查询机器全部资料', icon: <SearchOutlined />, onClick: () => router.push('/machines') },
{ title: '视频监控', desc: '查看厂房实时监控画面', icon: <VideoCameraOutlined />, onClick: () => router.push('/video') },
];
return (
<div style={{ paddingBottom: 24 }}>
{errorMessage && (
<Alert
type="error"
message={errorMessage}
showIcon
style={{ marginBottom: 16 }}
/>
)}
{/* 统计卡片区域 */}
<Row gutter={24} style={{ marginBottom: 24 }}>
{statCards.map((stat, idx) => (
<Col span={6} key={idx}>
<Card hoverable={!!stat.onClick} onClick={stat.onClick}>
<Statistic
title={stat.title}
value={stat.value}
suffix={stat.suffix}
prefix={stat.icon}
valueStyle={stat.valueStyle}
loading={!stats}
/>
</Card>
</Col>
))}
</Row>
{/* 快捷操作区域 */}
<Row gutter={24} style={{ marginBottom: 24 }}>
{quickActions.map((action, idx) => (
<Col span={8} key={idx}>
<Card
hoverable
onClick={action.onClick}
styles={{ body: { background: 'linear-gradient(135deg, #f6f8fc 0%, #eef2f9 100%)' } }}
>
<Flex align="center" gap={16}>
<Flex
align="center"
justify="center"
style={{
width: 56,
height: 56,
borderRadius: '50%',
background: '#fff',
boxShadow: '0 4px 12px rgba(24, 144, 255, 0.1)',
color: '#1890ff',
fontSize: 24
}}
>
{action.icon}
</Flex>
<Flex vertical>
<div style={{ fontWeight: 600, fontSize: 18 }}>{action.title}</div>
<div style={{ color: 'var(--color-text-secondary)', fontSize: 14 }}>{action.desc}</div>
</Flex>
</Flex>
</Card>
</Col>
))}
</Row>
<Row gutter={24}>
{/* 最近查验动态 */}
<Col span={12}>
<Card
title="最近查验动态"
extra={<Button type="link" icon={<RightOutlined />}></Button>}
styles={{ body: { height: 'calc(100% - 57px)' } }}
>
<List
itemLayout="horizontal"
dataSource={activities}
renderItem={(item) => (
<List.Item>
<List.Item.Meta
avatar={
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 }} />
}
title={<span style={{ fontWeight: 500 }}>{item.message}</span>}
description={item.time}
/>
</List.Item>
)}
/>
</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"
pagination={false}
size="small"
onRow={() => ({
onClick: () => router.push('/customs'),
style: { cursor: 'pointer' }
})}
>
<Table.Column title="报关单号" dataIndex="customsId" key="customsId" render={(text) => <b>{text}</b>} />
<Table.Column title="机器数量" dataIndex="machineCount" key="machineCount" render={(count) => `${count}`} />
<Table.Column title="状态" dataIndex="status" key="status" render={(status: string) => <StatusBadge status={status as 'pending' | 'inspecting' | 'released' | 'abnormal'} />} />
</Table>
</Card>
</Col>
</Row>
</div>
);
}
@@ -0,0 +1,166 @@
'use client';
import React, { useEffect, useState } from 'react';
import { Alert, Card, Row, Col, Button, Typography, Space, Spin, Flex, Breadcrumb as AntdBreadcrumb, Badge, Empty } from 'antd';
import { FullscreenOutlined, ReloadOutlined, CameraOutlined, ArrowLeftOutlined, CaretRightOutlined, HomeOutlined, VideoCameraOutlined } from '@ant-design/icons';
import { Breadcrumb } from '../../components/Breadcrumb';
import Link from 'next/link';
import { MockApi } from '../../services/mockApi';
import { 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('');
useEffect(() => {
let isMounted = true;
const loadCameras = async () => {
try {
setLoading(true);
setErrorMessage('');
const cameraList = await MockApi.getCameraList();
if (!isMounted) return;
setCameras(cameraList);
} catch {
if (!isMounted) return;
setErrorMessage('摄像头列表加载失败,请稍后重试');
} finally {
if (isMounted) {
setLoading(false);
}
}
};
loadCameras();
return () => {
isMounted = false;
};
}, []);
// 模拟的视频播放器组件
const MockVideoPlayer = ({ camera, height, aspectRatio }: { camera: CameraInfo, height?: number | string, aspectRatio?: string }) => (
<div style={{ position: 'relative', display: 'flex', flexDirection: 'column', alignItems: 'center', justifyContent: 'center', overflow: 'hidden', borderRadius: 8, background: '#141414', height, aspectRatio, boxShadow: 'inset 0 0 20px rgba(0,0,0,0.5)' }}>
{camera.status === 'online' ? (
<>
{/* 居中播放按钮 */}
<Button
type="text"
shape="circle"
icon={<CaretRightOutlined style={{ fontSize: 40, color: 'white' }} />}
style={{ width: 80, height: 80, backgroundColor: 'rgba(255, 255, 255, 0.15)', backdropFilter: 'blur(4px)', border: '1px solid rgba(255,255,255,0.2)' }}
/>
{/* 底部状态标识 */}
<div style={{ position: 'absolute', bottom: 16, left: 16, zIndex: 10, padding: '4px 10px', background: 'rgba(0,0,0,0.6)', borderRadius: 6, backdropFilter: 'blur(4px)' }}>
<Badge status="success" text={<Text style={{ color: 'white', fontSize: 12 }}>线 / </Text>} />
</div>
</>
) : (
<Empty
image={<VideoCameraOutlined style={{ fontSize: 64, color: '#ff4d4f', opacity: 0.9 }} />}
imageStyle={{ height: 64, marginBottom: 16 }}
description={
<Space direction="vertical" size={2}>
<Text type="danger" strong style={{ fontSize: 16 }}>线</Text>
<Text type="secondary" style={{ color: 'rgba(255,255,255,0.45)' }}></Text>
</Space>
}
>
<Button
type="primary"
danger
icon={<ReloadOutlined />}
style={{ marginTop: 8 }}
onClick={(e) => {
e.stopPropagation();
// 模拟重连
}}
>
</Button>
</Empty>
)}
</div>
);
if (fullscreenCamera) {
return (
<div>
<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={(e) => { e.preventDefault(); setFullscreenCamera(null); }}></a> },
{ title: fullscreenCamera.name }
]}
/>
</Space>
<Button type="primary" icon={<CameraOutlined />}></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)' }}>
<MockVideoPlayer camera={fullscreenCamera} height="100%" />
</div>
</Flex>
</div>
);
}
return (
<div>
<Flex justify="space-between" align="center" style={{ marginBottom: 24 }}>
<Breadcrumb />
<Space>
<Button icon={<FullscreenOutlined />}></Button>
<Button type="primary" icon={<CameraOutlined />}></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>
)}
<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)}
>
<MockVideoPlayer 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>
</Space>
{camera.status === 'online' && <Button type="link" icon={<FullscreenOutlined />} size="small"></Button>}
</Flex>
</Card>
</Col>
))}
</Row>
</div>
);
}
@@ -0,0 +1,50 @@
'use client';
import React from 'react';
import { Breadcrumb as AntdBreadcrumb } from 'antd';
import { HomeOutlined } from '@ant-design/icons';
import { usePathname } from 'next/navigation';
import Link from 'next/link';
const breadcrumbNameMap: Record<string, string> = {
'/video': '视频监控',
'/machines': '机器查询',
'/customs': '报关单管理',
'/inspection': '远程查验',
};
export const Breadcrumb: React.FC = () => {
const pathname = usePathname();
const pathSnippets = pathname.split('/').filter(i => i);
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>,
};
});
const breadcrumbItems = [
{
key: 'home',
title: <Link href="/"><HomeOutlined /> </Link>,
},
...extraBreadcrumbItems,
];
return (
<div style={{ marginBottom: 16 }}>
<AntdBreadcrumb items={breadcrumbItems} />
</div>
);
};
@@ -0,0 +1,58 @@
import React from 'react';
import { Tag } from 'antd';
import {
ClockCircleOutlined,
SyncOutlined,
CheckCircleOutlined,
WarningOutlined,
MinusCircleOutlined,
PauseCircleOutlined,
CloseCircleOutlined,
QuestionCircleOutlined
} 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 getStatusConfig = () => {
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' }} /> };
}
};
const config = getStatusConfig();
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,108 @@
'use client';
import React from 'react';
import { Layout, Menu, Badge, Avatar, Button, Dropdown, Space, Typography, theme } from 'antd';
import {
BellOutlined,
SettingOutlined,
UserOutlined,
DashboardOutlined,
VideoCameraOutlined,
SearchOutlined,
FileTextOutlined,
ScanOutlined,
SafetyCertificateOutlined
} from '@ant-design/icons';
import { usePathname, useRouter } from 'next/navigation';
import { useAppStore } from '../store/useAppStore';
const { Header } = Layout;
export const TopHeader: React.FC = () => {
const pathname = usePathname();
const router = useRouter();
const { user, notifications } = useAppStore();
const { token } = theme.useToken();
const unreadCount = notifications.filter(n => !n.read).length;
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 handleMenuClick = (e: { key: string }) => {
router.push(e.key);
};
const notificationMenu = {
items: notifications.map(n => ({
key: n.id,
label: (
<div style={{ width: 250, padding: '4px 0', whiteSpace: 'normal' }}>
<Typography.Text strong={!n.read} type={n.read ? 'secondary' : undefined} style={{ display: 'block' }}>
{n.title}
</Typography.Text>
<Typography.Paragraph
style={{ marginTop: 4, marginBottom: 0, color: token.colorTextSecondary, fontSize: 12, lineHeight: 1.5 }}
>
{n.message}
</Typography.Paragraph>
<Typography.Text style={{ marginTop: 4, display: 'block', color: token.colorTextTertiary, fontSize: 10 }}>
{n.time}
</Typography.Text>
</div>
)
}))
};
return (
<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 }} />
<Typography.Title level={4} style={{ margin: 0, color: token.colorPrimary, whiteSpace: 'nowrap' }}>
</Typography.Title>
</Space>
<Menu
mode="horizontal"
selectedKeys={[pathname === '/' ? '/' : `/${pathname.split('/')[1]}`]}
items={menuItems}
onClick={handleMenuClick}
style={{ flex: 1, minWidth: 0, borderBottom: 'none', lineHeight: '62px' }}
/>
<Space size="large" style={{ marginLeft: 24, display: 'flex', alignItems: 'center' }}>
<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, transition: 'background 0.3s' }} className="user-entry">
<Avatar size="small" icon={<UserOutlined />} style={{ backgroundColor: token.colorPrimary }} />
<Typography.Text style={{ fontSize: 14 }}>{user?.name}</Typography.Text>
</Space>
</Space>
</Header>
);
};
@@ -0,0 +1,122 @@
import {
CustomsStats,
ActivityItem,
CustomsDeclaration,
CameraInfo,
MachineDetail
} from '../types';
const customsDeclarations: CustomsDeclaration[] = [
{
id: '1', customsId: 'CD20260619001', status: 'pending', machineCount: 5, createdAt: '2026-06-19 14:00',
items: [
{ inventoryCode: 'P001', inventoryName: '打印机型号A', spec: 'A4', quantify: 3, inspected: 0 },
{ inventoryCode: 'S001', inventoryName: '扫描仪型号B', spec: 'A3', quantify: 2, inspected: 0 }
]
},
{
id: '2', customsId: 'CD20260619003', status: 'inspecting', machineCount: 8, createdAt: '2026-06-19 13:00',
items: [
{ inventoryCode: 'P002', inventoryName: '打印机型号C', spec: 'A4', quantify: 8, inspected: 2 }
]
},
{
id: '3', customsId: 'CD20260618004', status: 'released', machineCount: 10, createdAt: '2026-06-18 10:00',
items: [
{ inventoryCode: 'M001', inventoryName: '显示器型号A', spec: '27寸', quantify: 10, inspected: 10 }
]
},
];
// 模拟真实接口耗时,便于原型验证加载和错误状态。
const delay = (ms: number) => new Promise(resolve => setTimeout(resolve, ms));
export const MockApi = {
getCustomsStats: async (): Promise<CustomsStats> => {
await delay(300);
return {
pendingCount: 12,
releasedToday: 28,
inspectingCount: 1,
abnormalCount: 0
};
},
getRecentActivities: async (): Promise<ActivityItem[]> => {
await delay(300);
return [
{ id: '1', time: '14:30', type: 'start', message: 'CD20260619003 开始查验' },
{ id: '2', time: '14:15', type: 'success', message: 'CD20260619002 查验完成/放行' },
{ id: '3', time: '14:00', type: 'info', message: '新增报关单 CD20260618005' },
{ id: '4', time: '13:45', type: 'warning', message: 'CD20260618001 查验异常暂停' },
{ id: '5', time: '13:20', type: 'success', message: 'CD20260618004 查验完成/放行' },
];
},
getPendingCustoms: async (): Promise<CustomsDeclaration[]> => {
await delay(400);
return [
{ id: '1', customsId: 'CD20260619001', status: 'pending', machineCount: 5, createdAt: '2026-06-19 14:00', items: [] },
{ id: '2', customsId: 'CD20260619003', status: 'inspecting', machineCount: 8, createdAt: '2026-06-19 13:00', items: [] },
{ id: '3', customsId: 'CD20260619004', status: 'pending', machineCount: 3, createdAt: '2026-06-19 12:00', items: [] },
{ id: '4', customsId: 'CD20260618005', status: 'pending', machineCount: 6, createdAt: '2026-06-18 16:00', items: [] },
];
},
getCustomsList: async (): Promise<CustomsDeclaration[]> => {
await delay(500);
return customsDeclarations;
},
getCustomsById: async (customsId: string): Promise<CustomsDeclaration | null> => {
await delay(300);
return customsDeclarations.find(item => item.customsId === customsId) ?? null;
},
getCameraList: async (): Promise<CameraInfo[]> => {
await delay(300);
return [
{ id: '1', name: '摄像头 1', location: '入料口', streamUrl: '', status: 'online' },
{ id: '2', name: '摄像头 2', location: '生产线 A', streamUrl: '', status: 'online' },
{ id: '3', name: '摄像头 3', location: '生产线 B', streamUrl: '', status: 'online' },
{ id: '4', name: '摄像头 4', location: '出库区', streamUrl: '', status: 'offline' },
{ id: '5', name: '摄像头 5', location: 'AGV 作业区', streamUrl: '', status: 'online' },
];
},
getMachineDetail: async (serialNumber: string): Promise<MachineDetail> => {
await delay(400);
return {
serialNumber: serialNumber,
modelName: '打印机型号A',
modelId: 'MDL-A4-001',
customsId: 'CD20260619001',
customsName: '某科技公司进口设备批次',
status: 'pending',
specs: {
'尺寸': '480×320×260mm',
'重量': '12.5kg',
'产地': '中国 / 深圳',
'入库日期': '2026-06-15'
},
createdAt: '2026-06-15 10:00',
images: {
incomingInspection: [
{ id: 'i1', url: 'https://picsum.photos/800/600?1', thumbnailUrl: 'https://picsum.photos/200/150?1', name: '来料检验单 第1页', createdAt: '2026-06-10' },
{ id: 'i2', url: 'https://picsum.photos/800/600?2', thumbnailUrl: 'https://picsum.photos/200/150?2', name: '来料检验单 第2页', createdAt: '2026-06-10' }
],
startupTestSample: [
{ id: 'i3', url: 'https://picsum.photos/800/600?3', thumbnailUrl: 'https://picsum.photos/200/150?3', name: '开机测试样张', createdAt: '2026-06-12' }
],
productionOrder: [
{ id: 'i4', url: 'https://picsum.photos/800/600?4', thumbnailUrl: 'https://picsum.photos/200/150?4', name: '生产加工单', createdAt: '2026-06-12' }
],
robotInspection: [
{ id: 'i5', url: 'https://picsum.photos/800/600?5', thumbnailUrl: 'https://picsum.photos/200/150?5', name: '正面照', createdAt: '2026-06-19' },
{ id: 'i6', url: 'https://picsum.photos/800/600?6', thumbnailUrl: 'https://picsum.photos/200/150?6', name: '背面照', createdAt: '2026-06-19' }
]
},
inspectionRecords: []
};
}
};
@@ -0,0 +1,48 @@
import { create } from 'zustand';
import { User, Notification, CustomsDeclaration, InspectionState } from '../types';
interface AppState {
user: User | null;
notifications: Notification[];
selectedCustoms: CustomsDeclaration | null;
inspection: InspectionState | null;
// Actions
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: '14:30', read: false },
{ id: '2', title: '查验完成', message: '报关单 CD20260619002 查验完成', time: '14:15', read: false },
{ id: '3', title: '系统通知', message: '新增 5 份待查验报关单', time: '14:00', read: true }
],
selectedCustoms: null,
inspection: null,
setUser: (user) => set({ user }),
addNotification: (notification) => set((state) => ({
notifications: [notification, ...state.notifications]
})),
markNotificationRead: (id) => set((state) => ({
notifications: state.notifications.map(n =>
n.id === id ? { ...n, read: true } : n
)
})),
setSelectedCustoms: (selectedCustoms) => set({ selectedCustoms }),
setInspection: (inspection) => set({ inspection }),
updateInspectionStatus: (status) => set((state) => ({
inspection: state.inspection ? { ...state.inspection, status } : null
})),
}));
@@ -0,0 +1,99 @@
export interface User {
name: string;
role: string;
}
export interface Notification {
id: string;
title: string;
message: string;
time: string;
read: boolean;
}
export interface MachineDetail {
serialNumber: string;
modelName: string;
modelId: string;
customsId: string;
customsName: string;
status: 'pending' | 'inspecting' | 'released' | 'abnormal';
specs: Record<string, string>;
createdAt: string;
images: {
incomingInspection: ImageItem[];
startupTestSample: ImageItem[];
productionOrder: ImageItem[];
robotInspection: ImageItem[];
};
inspectionRecords: InspectionRecord[];
}
export interface ImageItem {
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 CustomsStats {
pendingCount: number;
releasedToday: number;
inspectingCount: number;
abnormalCount: number;
}
export interface CameraInfo {
id: string;
name: string;
location: string;
streamUrl: string;
status: 'online' | 'offline';
snapshot?: string;
}
export interface CustomsDeclaration {
id: string;
customsId: string;
status: 'pending' | 'released' | 'abnormal' | 'inspecting';
machineCount: number;
createdAt: string;
items: InspectionItem[];
}
export interface InspectionState {
customsId: string;
customsName: string;
status: 'idle' | 'running' | 'paused' | 'completed';
items: InspectionItem[];
startedAt: number;
currentMachine?: {
machineId: string;
serialNumber: string;
step: string;
};
}
export interface InspectionItem {
inventoryCode: string;
inventoryName: string;
spec: string;
quantify: number;
inspected: number;
}
export interface ActivityItem {
id: string;
time: string;
type: 'start' | 'success' | 'info' | 'warning';
message: 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"]
}
+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/start_all.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"
# 重启 Flask
ssh elephant@192.168.60.80 'bash -s' < scripts/restart_flask.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/scan_fixer/`,由 `scripts/start_all.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个数据配置文件
- 结合记忆条目中的经验教训和已知问题
+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
+96
View File
@@ -0,0 +1,96 @@
# AGV 智能巡检系统 — 脚本说明
## 目录结构
```
scripts/
├── start_all.sh ← 生产环境完整启动(ROS2 + Nav2 + Flask
├── stop_all.sh ← 生产环境完整停止
├── start_flask.sh ← 仅重启 Flask(修改代码后快速部署)
├── restart_flask.sh ← 语法检查 + 清缓存 + 重启 Flask + 验证
└── dev_start.sh ← 本地开发用(前台运行,不启动 ROS2)
```
## 使用场景
### 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/start_all.sh # 完整启动
```
### 2. 修改代码后快速部署
```bash
# 部署文件到 AGV 后
ssh elephant@192.168.60.80 'bash -s' < scripts/restart_flask.sh
```
### 3. 本地开发调试(不连硬件)
```bash
# 在本机执行,仅启动 Flask
./scripts/dev_start.sh
# 访问 http://127.0.0.1:5000
```
### 4. 远程轻量重启(ROS2 已运行)
```bash
ssh elephant@192.168.60.80 'bash -s' < scripts/start_flask.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 工作空间 |
| `SCAN_FIXER_DIR` | `/home/elephant/work/scan_fixer` | 时间戳修正工具目录 |
| `FIXER_SCRIPT` | `fix_scan_timestamp_v6.py` | fixer 脚本名 |
## 日志位置(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`
+43
View File
@@ -0,0 +1,43 @@
#!/bin/bash
# ============================================================
# dev_start.sh - 本地开发环境启动(不启动 ROS2/机械臂硬件)
# 用法: ./scripts/dev_start.sh
# ============================================================
set -e
SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)"
PROJECT_DIR="$(dirname "$SCRIPT_DIR")"
AGV_APP_DIR="$PROJECT_DIR/agv_app"
AGV_ROS2_DIR="${AGV_ROS2_DIR:-/home/elephant/agv_pro_ros2}"
echo "=========================================="
echo " 本地开发模式 - 仅启动 Flask"
echo "=========================================="
echo ""
# 切换到项目目录
source /opt/ros/humble/setup.bash 2>/dev/null || true
source "$AGV_ROS2_DIR/install/setup.bash" 2>/dev/null || true
cd "$AGV_APP_DIR"
# 检查是否有运行的 Flask 进程
FLASK_PID=$(pgrep -f "python.*app.py" 2>/dev/null || true)
if [ -n "$FLASK_PID" ]; then
echo "Flask 已在运行 (PID: $FLASK_PID)"
read -p "是否重启? [y/N] " -n 1 -r
echo
if [[ $REPLY =~ ^[Yy]$ ]]; then
kill "$FLASK_PID" 2>/dev/null
sleep 1
else
echo "保持现有进程,退出"
exit 0
fi
fi
# 使用前台模式运行(方便看日志和 Ctrl+C 停止)
echo "启动 Flask (前台模式,Ctrl+C 停止)..."
echo "访问: http://127.0.0.1:5000"
echo ""
exec uv run --locked python app.py
+65
View File
@@ -0,0 +1,65 @@
#!/bin/bash
# ============================================================
# restart_flask.sh - 语法检查 + 重启 Flask + 验证
# 用法: ssh elephant@<AGV_IP> 'bash -s' < scripts/restart_flask.sh
# 或在 AGV 上: cd ~/work/smart-inspection && ./scripts/restart_flask.sh
# ============================================================
set -e
AGV_PROJECT_DIR="${AGV_PROJECT_DIR:-/home/elephant/work/smart-inspection}"
AGV_APP_DIR="${AGV_APP_DIR:-$AGV_PROJECT_DIR/agv_app}"
AGV_ROS2_DIR="${AGV_ROS2_DIR:-/home/elephant/agv_pro_ros2}"
source /opt/ros/humble/setup.bash 2>/dev/null || true
source "$AGV_ROS2_DIR/install/setup.bash" 2>/dev/null || true
cd "$AGV_APP_DIR"
echo "=========================================="
echo " 重启 Flask 服务"
echo "=========================================="
echo ""
# 1. 语法检查
echo "[1/3] Python 语法检查..."
uv run --locked python -m py_compile app.py
if [ $? -ne 0 ]; then
echo "❌ 语法错误,请先修复"
exit 1
fi
echo " ✅ 语法检查通过"
# 2. 清缓存 + 重启
echo "[2/3] 清理缓存并重启..."
find "$AGV_APP_DIR" -name '*.pyc' -delete 2>/dev/null
find "$AGV_APP_DIR" -name '__pycache__' -type d -exec rm -rf {} + 2>/dev/null
pkill -f "python.*app.py" 2>/dev/null || true
pkill -f "uv run .*python app.py" 2>/dev/null || true
sleep 1
nohup uv run --locked python app.py > /tmp/agv_flask.log 2>&1 &
FLASK_PID=$!
echo " Flask PID: $FLASK_PID"
# 3. 验证
echo "[3/3] 验证服务..."
sleep 3
if ss -tlnp 2>/dev/null | grep -q 5000 || netstat -tlnp 2>/dev/null | grep -q 5000; then
echo " ✅ 端口 5000 正常监听"
# 测试机械臂摄像头单帧
result=$(curl -s --max-time 5 http://127.0.0.1:5000/api/camera/arm_refresh 2>/dev/null | head -c 4)
if [ "$result" = "$(echo -en '\xff\xd8\xff\xe0')" ]; then
echo " ✅ arm_refresh 返回 JPEG"
else
echo " ⚠️ arm_refresh 返回异常(机械臂可能未连接)"
fi
else
echo " ❌ 端口 5000 未监听,查看日志:"
tail -10 /tmp/agv_flask.log
exit 1
fi
echo ""
echo "=========================================="
echo " ✅ 重启完成"
echo "=========================================="
+12 -7
View File
@@ -9,8 +9,12 @@
# ============================================================
set -e
AGV_APP_DIR="/home/elephant/work/agv_app"
AGV_ROS2_DIR="/home/elephant/agv_pro_ros2"
# ---- 可配置项(环境变量覆盖默认值) ----
AGV_PROJECT_DIR="${AGV_PROJECT_DIR:-/home/elephant/work/smart-inspection}"
AGV_APP_DIR="${AGV_APP_DIR:-$AGV_PROJECT_DIR/agv_app}"
AGV_ROS2_DIR="${AGV_ROS2_DIR:-/home/elephant/agv_pro_ros2}"
SCAN_FIXER_DIR="${SCAN_FIXER_DIR:-/home/elephant/work/scan_fixer}"
FIXER_SCRIPT="${FIXER_SCRIPT:-fix_scan_timestamp_v6.py}"
ROS_DOMAIN_ID_VAL=1
echo "=========================================="
@@ -31,6 +35,7 @@ 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
pkill -f "uv run .*python app.py" 2>/dev/null || true
sleep 2
# 【关键】硬杀确保干净
@@ -138,7 +143,7 @@ fi
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" \
ROS_DOMAIN_ID=$ROS_DOMAIN_ID_VAL python3 $SCAN_FIXER_DIR/clock_publisher.py" \
> /tmp/clock_publisher.log 2>&1 &
CLOCK_PID=$!
echo " clock_publisher PID: $CLOCK_PID"
@@ -170,7 +175,7 @@ if [ "$SCAN_OK" -eq 0 ]; then
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" \
ROS_DOMAIN_ID=$ROS_DOMAIN_ID_VAL python3 $SCAN_FIXER_DIR/$FIXER_SCRIPT" \
> /tmp/scan_fixer.log 2>&1 &
FIXER_PID=$!
echo " fix_scan_timestamp PID: $FIXER_PID"
@@ -185,7 +190,7 @@ 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" \
ROS_DOMAIN_ID=$ROS_DOMAIN_ID_VAL python3 $SCAN_FIXER_DIR/$FIXER_SCRIPT" \
> /tmp/scan_fixer.log 2>&1 &
FIXER_PID=$!
sleep 3
@@ -249,7 +254,7 @@ echo " ✅ 精度参数已设置"
echo "[7/8] 启动 Flask API..."
export ROS_DOMAIN_ID=1
cd "$AGV_APP_DIR"
nohup python3 app.py > /tmp/agv_flask.log 2>&1 &
nohup uv run --locked python app.py > /tmp/agv_flask.log 2>&1 &
FLASK_PID=$!
echo " Flask PID: $FLASK_PID"
sleep 4
@@ -305,7 +310,7 @@ echo " 当前文件数: $FASTRTPS_NEW (正常运行时会有一些)"
# 8e. Flask API 测试
echo ""
echo "验证 Flask API..."
FLASK_RUNNING=$(ps aux | grep "[p]ython3 app.py" | wc -l || echo 0)
FLASK_RUNNING=$(pgrep -f "app.py" | wc -l || echo 0)
if [ "$FLASK_RUNNING" -gt 0 ]; then
echo " ✅ Flask 进程运行中"
else
+26
View File
@@ -0,0 +1,26 @@
#!/bin/bash
# ============================================================
# start_flask.sh - 仅启动/重启 Flask 服务(不启动 ROS2)
# 适用于: 修改了前端/API 代码后快速重启
# ============================================================
AGV_PROJECT_DIR="${AGV_PROJECT_DIR:-/home/elephant/work/smart-inspection}"
AGV_APP_DIR="${AGV_APP_DIR:-$AGV_PROJECT_DIR/agv_app}"
AGV_ROS2_DIR="${AGV_ROS2_DIR:-/home/elephant/agv_pro_ros2}"
pkill -f "python.*app.py" 2>/dev/null || true
pkill -f "uv run .*python app.py" 2>/dev/null || true
sleep 1
source /opt/ros/humble/setup.bash 2>/dev/null || true
source "$AGV_ROS2_DIR/install/setup.bash" 2>/dev/null || true
cd "$AGV_APP_DIR"
nohup uv run --locked python app.py > /tmp/agv_flask.log 2>&1 &
echo "Flask started, PID: $!"
sleep 2
if ss -tlnp 2>/dev/null | grep -q 5000 || netstat -tlnp 2>/dev/null | grep -q 5000; then
echo "✅ 端口 5000 正常"
else
echo "⚠️ 端口 5000 未监听,检查 /tmp/agv_flask.log"
fi
+2 -1
View File
@@ -14,7 +14,8 @@ echo ""
# ---------- 1. 软杀所有相关进程 ----------
echo "[1/5] 软杀所有相关进程..."
pkill -f "python3 app.py" 2>/dev/null || true
pkill -f "python.*app.py" 2>/dev/null || true
pkill -f "uv run .*python 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
-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" },
]