Compare commits

...

3 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
55 changed files with 10816 additions and 2182 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
+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: $!"
-1045
View File
File diff suppressed because it is too large Load Diff
-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()
-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.80"
ARM_HOST = "192.168.60.120"
# ========== AGV 参数 ==========
AGV_CONFIG = {
"device": "/dev/agvpro_controller",
"baudrate": 10000000,
"move_speed": 1.0,
"turn_speed": 1.0,
}
# ========== 机械臂 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 = 1000
# ========== 状态定义 ==========
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
+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()
+4 -2
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")
+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.80",
"ssh_user": "elephant",
"ssh_password": "Elephant",
"map_file": "map.yaml",
"map_dir": "/home/elephant"
},
"arm": {
"ip": "192.168.60.120",
"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" },
]