Compare commits

..

10 Commits

Author SHA1 Message Date
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
16 changed files with 1613 additions and 205 deletions
+538 -62
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:
break
# 编码为 JPEG
if _time.time() - _last_ok > 5:
break
_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,27 +1124,98 @@ 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
try:
r = requests.get(ARM_CAMERA_CONFIG.get("snapshot_url", ARM_CAMERA_CONFIG["url"]), timeout=8)
if r.status_code == 200 and r.content:
resp = Response(r.content, mimetype="image/jpeg")
resp.headers["Cache-Control"] = "no-cache, no-store, must-revalidate, max-age=0"
resp.headers["Pragma"] = "no-cache"
resp.headers["Expires"] = "0"
return resp
return "", 404
except Exception as ex:
logger.info(f"arm_refresh 不可用: {ex}")
return "", 404
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(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"
resp.headers["Expires"] = "0"
return resp
return "", 404
except Exception as ex:
logger.info(f"arm_refresh 尝试{attempt}/{max_retries} 失败: {ex}")
time.sleep(0.5)
logger.warning(f"arm_refresh 在 {max_retries} 次尝试后仍失败")
return "", 404
@app.route("/api/camera/arm_preview")
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 检测
detector = cv2.QRCodeDetector()
result, _, _ = detector.detectAndDecode(frame)
if result and len(result.strip()) > 0:
result = result.strip()
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()
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:
+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;
}
+52 -2
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) {
+180 -6
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')
+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>
+145 -12
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>
<!-- 手动输入二维码弹窗 -->
<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>
+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
+23 -14
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(",")]
return True, angles
# 兼容 "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(",")]
return True, coords
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
+5 -5
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 客户端 ==========
@@ -77,7 +77,7 @@ JOINT_LIMITS = {
}
# ========== 机械臂默认速度 ==========
DEFAULT_ARM_SPEED = 500
DEFAULT_ARM_SPEED = 1000
# ========== 状态定义 ==========
class State:
+154 -36
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,23 +736,41 @@ class MissionExecutorV3:
time.sleep(0.5)
return None
def _request_manual_qr(self) -> Optional[str]:
"""暂停任务,等待手动输入(不超时,必须输入才能继续;stop 时解除)"""
self.status = MissionStatus.WAITING_QR
self.report["status"] = "waiting_qr"
self.report["step"] = "等待手动输入二维码"
self._log(" ⌨️ 弹窗等待手动输入二维码(不可跳过)...")
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"] = 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() 触发
self.status = MissionStatus.RUNNING
self.report["status"] = "running"
if self._qr_value:
self._log(f" ✏️ 手动输入: {self._qr_value}")
return self._qr_value
else:
self._log(f" ⚠️ 任务已停止")
return None
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:
self._log(f" ✏️ 手动输入: {self._qr_value}")
return self._qr_value
else:
self._log(f" ⚠️ 任务已停止")
return None
def set_manual_qr(self, value: str):
self._qr_value = value.strip()
@@ -762,8 +779,73 @@ class MissionExecutorV3:
# ==================== 机型查询 ====================
def _lookup_model(self, qr_value: Optional[str]) -> str:
"""TODO: 后续通过 HTTP 接口查询机型"""
return "机器1"
"""通过 /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,14 @@ 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
@@ -879,6 +970,33 @@ class MissionExecutorV3:
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
else:
self._log(f" ⚠️ 上传失败 [{index}] HTTP {resp.status_code}: {resp.text[:200]}")
return False
except Exception as e:
self._log(f" ❌ 上传异常 [{index}]: {e}")
return False
# ==================== 控制 ====================
def _wait_pause(self):
+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 self._fix_frame(frame)
except FuturesTimeout:
logger.warning(f"摄像头 read_frame 超时 ({timeout}s),尝试重建 _cap")
self.close()
self.open()
# 重建后重试一次
if self._cap and self._cap.isOpened():
ret, frame = self._cap.read()
if ret and frame is not None:
return self._fix_frame(frame)
return None
return frame
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]:
"""从图像帧中检测二维码"""
+53 -17
View File
@@ -105,11 +105,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 +254,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 +330,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)
arm_server_http = make_server("0.0.0.0", 5003, arm_video_app, threaded=True)
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")
+2 -2
View File
@@ -1,13 +1,13 @@
{
"agv": {
"ip": "192.168.60.177",
"ip": "192.168.60.80",
"ssh_user": "elephant",
"ssh_password": "Elephant",
"map_file": "map.yaml",
"map_dir": "/home/elephant"
},
"arm": {
"ip": "192.168.60.88",
"ip": "192.168.60.120",
"ssh_user": "pi",
"ssh_password": "elephant",
"socket_port": 5001,