This commit is contained in:
ywb
2026-05-14 21:43:35 +08:00
commit 94c1043f4d
40 changed files with 8602 additions and 0 deletions
Vendored
BIN
View File
Binary file not shown.
+3
View File
@@ -0,0 +1,3 @@
from .app import app
__all__ = ["app"]
+1205
View File
File diff suppressed because it is too large Load Diff
+87
View File
@@ -0,0 +1,87 @@
"""
配置文件 - 所有可配置参数集中管理
"""
import os
# 基础路径(部署后对应 ~/work/agv_app
BASE_DIR = os.path.dirname(os.path.abspath(__file__))
# ========== AGV 参数 ==========
AGV_CONFIG = {
"device": "/dev/agvpro_controller",
"baudrate": 10000000,
"move_speed": 0.5,
"turn_speed": 0.5,
}
# ========== 机械臂 TCP 客户端 ==========
ARM_CONFIG = {
"host": "192.168.110.164",
"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": "http://192.168.110.164:5003/api/camera/preview",
}
# ========== HTTP 上传 ==========
UPLOAD_CONFIG = {
"url": "https://ts.zhijian168.com/prod-api/file/uploadImage",
"timeout": 30,
"max_retries": 3,
}
# ========== Flask 服务器 ==========
SERVER_CONFIG = {
"host": "0.0.0.0",
"port": 5000,
"secret_key": "agv630_secret_key_2024",
"debug": False,
}
# ========== 任务配置存储路径 ==========
DATA_DIR = os.path.join(BASE_DIR, "data")
os.makedirs(DATA_DIR, exist_ok=True)
# ========== 关节角度范围限制 ==========
JOINT_LIMITS = {
"J1": (-180.0, 180.0),
"J2": (-270.0, 90.0),
"J3": (-150.0, 150.0),
"J4": (-260.0, 80.0),
"J5": (-168.0, 168.0),
"J6": (-174.0, 174.0),
}
# ========== 机械臂默认速度 ==========
DEFAULT_ARM_SPEED = 500
# ========== 状态定义 ==========
class State:
SETTING = "setting"
RUNNING = "running"
PAUSED = "paused"
IDLE = "idle"
class PhotoType:
FRONT = "front"
BACK = "back"
NAMEPLATE = "nameplate"
+296
View File
@@ -0,0 +1,296 @@
[
{
"id": "m_0_2",
"row": 0,
"col": 2,
"front": {
"coords": [
1.2421705407118802,
0.0025490140048510445,
0.00150923641
],
"poses": []
},
"back": {
"coords": [
0,
0,
0
],
"poses": []
}
},
{
"id": "m_1_2",
"row": 1,
"col": 2,
"front": {
"coords": [
0,
0,
0
],
"poses": []
},
"back": {
"coords": [
0,
0,
0
],
"poses": []
}
},
{
"id": "m_2_0",
"row": 2,
"col": 0,
"front": {
"coords": [
0,
0,
0
],
"poses": []
},
"back": {
"coords": [
0,
0,
0
],
"poses": []
}
},
{
"id": "m_2_2",
"row": 2,
"col": 2,
"front": {
"coords": [
0,
0,
0
],
"poses": []
},
"back": {
"coords": [
0,
0,
0
],
"poses": []
}
},
{
"id": "m_3_0",
"row": 3,
"col": 0,
"front": {
"coords": [
0,
0,
0
],
"poses": []
},
"back": {
"coords": [
0,
0,
0
],
"poses": []
}
},
{
"id": "m_3_2",
"row": 3,
"col": 2,
"front": {
"coords": [
0,
0,
0
],
"poses": []
},
"back": {
"coords": [
0,
0,
0
],
"poses": []
}
},
{
"id": "m_3_1",
"row": 3,
"col": 1,
"front": {
"coords": [
0,
0,
0
],
"poses": []
},
"back": {
"coords": [
0,
0,
0
],
"poses": []
}
},
{
"id": "m_4_4",
"row": 4,
"col": 4,
"front": {
"coords": [
0,
0,
0
],
"poses": []
},
"back": {
"coords": [
0,
0,
0
],
"poses": []
}
},
{
"id": "m_4_1",
"row": 4,
"col": 1,
"front": {
"coords": [
0,
0,
0
],
"poses": []
},
"back": {
"coords": [
0,
0,
0
],
"poses": []
}
},
{
"id": "m_4_0",
"row": 4,
"col": 0,
"front": {
"coords": [
0,
0,
0
],
"poses": []
},
"back": {
"coords": [
0,
0,
0
],
"poses": []
}
},
{
"id": "m_3_4",
"row": 3,
"col": 4,
"front": {
"coords": [
0,
0,
0
],
"poses": []
},
"back": {
"coords": [
0,
0,
0
],
"poses": []
}
},
{
"id": "m_3_3",
"row": 3,
"col": 3,
"front": {
"coords": [
0,
0,
0
],
"poses": []
},
"back": {
"coords": [
0,
0,
0
],
"poses": []
}
},
{
"id": "m_0_1",
"row": 0,
"col": 1,
"front": {
"coords": [
0,
0,
0
],
"poses": []
},
"back": {
"coords": [
0,
0,
0
],
"poses": []
}
},
{
"id": "m_0_0",
"row": 0,
"col": 0,
"front": {
"coords": [
0,
0,
0
],
"poses": []
},
"back": {
"coords": [
0,
0,
0
],
"poses": []
}
}
]
+5
View File
@@ -0,0 +1,5 @@
{
"map_dir": "/home/elephant/agv_pro_ros2/src/agv_pro_navigation2/map/",
"map_file": "map.yaml",
"map_yaml": "/home/elephant/agv_pro_ros2/src/agv_pro_navigation2/map/map.yaml"
}
+128
View File
@@ -0,0 +1,128 @@
{
"rows": 2,
"cols": 2,
"grid": [],
"positions": [
{
"row": 0,
"col": 0,
"side": "front",
"coords": [
0.616485726055098,
-0.002587517923224651,
-0.003483980050000001
],
"poses": []
},
{
"row": 3,
"col": 1,
"side": "back",
"coords": [
0,
0,
0
],
"poses": []
},
{
"row": 1,
"col": 1,
"side": "front",
"coords": [
-0.27906358987415997,
0.00411087876725537,
-0.00749475593
],
"poses": []
},
{
"row": 0,
"col": 1,
"side": "front",
"coords": [
0.616485726055098,
-0.002587517923224651,
-0.003483980050000001
],
"poses": []
},
{
"row": 0,
"col": 2,
"side": "front",
"coords": [
-0.27906358987415997,
0.00411087876725537,
-0.00749475593
],
"poses": []
},
{
"row": 2,
"col": 1,
"side": "shoot",
"coords": [
-1.898244121263206,
-0.014324627152337432,
0.004533442980000002
],
"poses": []
},
{
"row": 2,
"col": 0,
"side": "shoot",
"coords": [
-0.9528404539697249,
-0.01004755255507813,
0.005515614170000002
],
"poses": []
},
{
"row": 0,
"col": 1,
"side": "shoot",
"coords": [
-0.9528404539697249,
-0.01004755255507813,
0.005515614170000002
],
"poses": []
},
{
"row": 1,
"col": 1,
"side": "shoot",
"coords": [
-0.9528404539697249,
-0.01004755255507813,
0.005515614170000002
],
"poses": []
},
{
"row": 0,
"col": 0,
"side": "shoot",
"coords": [
0,
0,
0
],
"poses": []
},
{
"row": 1,
"col": 0,
"side": "shoot",
"coords": [
0,
0,
0
],
"poses": []
}
]
}
+55
View File
@@ -0,0 +1,55 @@
[
{
"coords": [
-0.2489133152442747,
-0.9566827357283122,
1.3165501267
],
"id": "p_1778482526",
"name": "point_1",
"photo_mode": "front",
"poses": [
{
"arm_angles": [],
"id": "pose_1778483465",
"name": "姿态1",
"photo_type": "front",
"speed": 500
}
],
"sequence": [
"front",
"back"
]
},
{
"coords": [
-0.13938025759948866,
-0.5310313938681763,
1.3225773811300001
],
"id": "p_1778482605",
"name": "point_2",
"photo_mode": "front",
"poses": [],
"sequence": [
"front",
"back"
]
},
{
"coords": [
-0.5498454634407133,
0.4294772846745445,
2.083953415929999
],
"id": "p_1778483433",
"name": "point_3",
"photo_mode": "front",
"poses": [],
"sequence": [
"front",
"back"
]
}
]
+7
View File
@@ -0,0 +1,7 @@
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
+385
View File
@@ -0,0 +1,385 @@
<!DOCTYPE html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>设置 - AGV 拍摄系统</title>
<link rel="stylesheet" href="/static/css/style.css?v=20260514a">
</head>
<body>
<div id="app">
<header class="topbar">
<div class="logo">⚙️ 系统设置</div>
<nav class="nav">
<a href="/" class="nav-link">🏠 首页</a>
<href="/setting" class="nav-link active">⚙️ 设置</a>
<a href="/running" class="nav-link">▶️ 运行</a>
</nav>
</header>
<!-- Tabs -->
<div class="tabs">
<button class="tab" :class="{active: tab === 'map'}" @click="tab = 'map'">🗺️ 地图</button>
<button class="tab" :class="{active: tab === 'mission'}" @click="tab = 'mission'">🎯 任务配置</button>
<button class="tab" :class="{active: tab === 'arm'}" @click="tab = 'arm'">🤖 机械臂</button>
<button class="tab" :class="{active: tab === 'agv'}" @click="tab = 'agv'">🚗 AGV控制</button>
</div>
<main class="container">
<!-- 地图配置 (保持不变) -->
<div v-if="tab === 'map'">
<section class="card">
<h2>地图配置</h2>
<div class="form-row">
<div class="form-group">
<label>地图目录</label>
<input type="text" v-model="mapForm.map_dir" placeholder="/home/elephant/...">
</div>
<div class="form-group">
<label>地图文件</label>
<input type="text" v-model="mapForm.map_file" placeholder="map.yaml">
</div>
<div class="form-group" style="align-self:end">
<button class="btn btn-primary" @click="loadMap">📂 加载地图</button>
<button class="btn btn-secondary" @click="saveMap" style="margin-left:6px">💾 保存</button>
</div>
</div>
<p v-if="mapMsg" class="hint">{% raw %}{{ mapMsg }}{% endraw %}</p>
</section>
<section class="card" v-if="mapLoaded" style="margin-top:16px">
<h2>地图可视化</h2>
<div class="map-container" style="position:relative;background:#111;border-radius:8px;overflow:hidden">
<img :src="mapImageUrl" @error="onMapError" style="width:100%;display:block">
<!-- 地图覆盖层:显示点位坐标 -->
<div class="map-overlay">
<!-- 点位坐标点 -->
<div v-for="(p, pi) in missionConfig.positions" :key="'pdot-'+pi"
class="map-dot point-dot"
:style="{ left: getMapX(p.coords) + '%', top: getMapY(p.coords) + '%' }"
:title="p.coords ? p.coords.map(c => c.toFixed(2)).join(', ') : ''">
</div>
</div>
</div>
</section>
</div>
<!-- ========== 任务配置 Tab ========== -->
<div v-if="tab === 'mission'">
<!-- 上:网格配置 -->
<section class="card">
<h2>① 网格配置 (M×N)</h2>
<div class="form-row">
<div class="form-group">
<label>行数 M</label>
<input type="number" v-model.number="missionConfig.rows" min="1" max="20" placeholder="3">
</div>
<div class="form-group">
<label>列数 N</label>
<input type="number" v-model.number="missionConfig.cols" min="1" max="20" placeholder="4">
</div>
<div class="form-group" style="align-self:end">
<button class="btn btn-primary" @click="generateGrid">🔲 生成网格</button>
<button class="btn btn-secondary" @click="saveMissionConfig" style="margin-left:6px">💾 保存网格</button>
</div>
</div>
<!-- 网格可视化 - 点位行独立于机器,始终可配置 -->
<div v-if="missionConfig.rows > 0" class="mission-grid-wrap" style="margin-top:12px">
<div class="mission-grid" :style="{ gridTemplateColumns: '90px repeat(' + missionConfig.cols + ', 100px)' }">
<!-- 表头: 列号 -->
<div class="grid-cell grid-header"></div>
<div v-for="c in missionConfig.cols" :key="'h'+c" class="grid-cell grid-header">第{% raw %}{{ c }}{% endraw %}列</div>
<!-- 循环渲染: 点位行(0) → 机器行(1) → 点位行(1) → 机器行(2) → ... → 点位行(rows) -->
<!-- pointRow 从 0 到 rows(共 rows+1 个点位行)-->
<!-- machineRow 从 1 到 rows(共 rows 个机器行)-->
<!-- 第一个点位行 (pointRow=0): 所有机器的正面拍摄点 -->
<div class="grid-cell grid-header">点位行 1</div>
<div v-for="(ci) in missionConfig.cols" :key="'p0_'+ci"
class="grid-cell point-cell"
@click="openPointEdit(0, ci-1)">
<span class="point-coords">{% raw %}{{ getPointAt(0, ci-1)?.coords?.[0]?.toFixed(1) || '-' }},{{ getPointAt(0, ci-1)?.coords?.[1]?.toFixed(1) || '-' }}{% endraw %}</span>
<button class="btn-icon-small" title="配置坐标" @click.stop="openPointEdit(0, ci-1)">+</button>
</div>
<!-- 中间循环: 机器行 ri + 点位行 ri (ri from 1 to rows-1) -->
<template v-for="ri in (missionConfig.rows - 1)" :key="'mr'+ri">
<!-- 机器行 ri -->
<div class="grid-cell grid-header">机器行 {% raw %}{{ ri }}{% endraw %}</div>
<div v-for="(ci) in missionConfig.cols" :key="'m'+ri+'_'+ci"
class="grid-cell"
:class="{ active: getMachineAt(ri-1, ci-1) }"
@click="onCellClick(ri-1, ci-1)">
<template v-if="getMachineAt(ri-1, ci-1)">
<div class="cell-machine"></div>
</template>
<span v-else class="empty-cell"></span>
</div>
<!-- 点位行 ri+1 (pointRow=ri): 上面机器的背面 / 下面机器的正面 -->
<div class="grid-cell grid-header">点位行 {% raw %}{{ ri+1 }}{% endraw %}</div>
<div v-for="(ci) in missionConfig.cols" :key="'p'+(ri)+'_'+ci"
class="grid-cell point-cell"
@click="openPointEdit(ri, ci-1)">
<span class="point-coords">{% raw %}{{ getPointAt(ri, ci-1)?.coords?.[0]?.toFixed(1) || '-' }},{{ getPointAt(ri, ci-1)?.coords?.[1]?.toFixed(1) || '-' }}{% endraw %}</span>
<button class="btn-icon-small" title="配置坐标" @click.stop="openPointEdit(ri, ci-1)">+</button>
</div>
</template>
<!-- 最后一个机器行 (机器行 rows) -->
<div class="grid-cell grid-header">机器行 {% raw %}{{ missionConfig.rows }}{% endraw %}</div>
<div v-for="(ci) in missionConfig.cols" :key="'m'+missionConfig.rows+'_'+ci"
class="grid-cell"
:class="{ active: getMachineAt(missionConfig.rows-1, ci-1) }"
@click="onCellClick(missionConfig.rows-1, ci-1)">
<template v-if="getMachineAt(missionConfig.rows-1, ci-1)">
<div class="cell-machine"></div>
</template>
<span v-else class="empty-cell"></span>
</div>
<!-- 最后一个点位行 (pointRow=rows): 所有机器的背面拍摄点 -->
<div class="grid-cell grid-header">点位行 {% raw %}{{ missionConfig.rows+1 }}{% endraw %}</div>
<div v-for="(ci) in missionConfig.cols" :key="'p'+missionConfig.rows+'_'+ci"
class="grid-cell point-cell"
@click="openPointEdit(missionConfig.rows, ci-1)">
<span class="point-coords">{% raw %}{{ getPointAt(missionConfig.rows, ci-1)?.coords?.[0]?.toFixed(1) || '-' }},{{ getPointAt(missionConfig.rows, ci-1)?.coords?.[1]?.toFixed(1) || '-' }}{% endraw %}</span>
<button class="btn-icon-small" title="配置坐标" @click.stop="openPointEdit(missionConfig.rows, ci-1)">+</button>
</div>
</div>
<p class="hint" style="margin-top:8px">点击「点位行」配置拍摄坐标;点击「机器行」切换有无机器<br>中间点位同时服务于上下两台机器(上机器背面 / 下机器正面),删除机器不影响点位配置</p>
</div>
</section>
<!-- 中:选中机器的配置 -->
<section class="card" v-if="selectedMachine && selectedMachine.front && selectedMachine.back" style="margin-top:16px">
<h2>② 点位配置 — 第{% raw %}{{ selectedMachine.row+1 }}{% endraw %}行 第{% raw %}{{ selectedMachine.col+1 }}{% endraw %}列 <button class="btn btn-small" @click="clearSelection()">← 返回</button></h2>
<!-- 正面点位 -->
<div class="machine-form">
<h3>📷 正面点位</h3>
<div class="form-row">
<div class="form-group">
<label>X 坐标</label>
<input type="number" step="0.01" v-model.number="selectedMachine.front.coords[0]" placeholder="0.00">
</div>
<div class="form-group">
<label>Y 坐标</label>
<input type="number" step="0.01" v-model.number="selectedMachine.front.coords[1]" placeholder="0.00">
</div>
<div class="form-group">
<label>Yaw (弧度)</label>
<input type="number" step="0.01" v-model.number="selectedMachine.front.coords[2]" placeholder="0.00">
</div>
<div class="form-group" style="align-self:end">
<button class="btn btn-small btn-primary" @click="readPosition('front')" :disabled="!agvConnected">📍 读取当前位置</button>
</div>
</div>
<!-- 正面姿态列表 -->
<div v-if="selectedMachine.front.poses && selectedMachine.front.poses.length > 0" class="pose-list">
<h4>正面姿态 ({% raw %}{{ selectedMachine.front.poses.length }}{% endraw %} 个)</h4>
<div v-for="pose in selectedMachine.front.poses" :key="pose.id" class="pose-item">
<span class="pose-name">{% raw %}{{ pose.name }}{% endraw %}</span>
<span class="pose-angles" v-if="pose.arm_angles">角度: {% raw %}{{ formatAngles(pose.arm_angles) }}{% endraw %}</span>
<button class="btn-icon" @click="deletePose(selectedMachine.id, 'front', pose.id)">🗑️</button>
</div>
</div>
<div class="pose-add">
<input type="text" v-model="poseForm.name" placeholder="姿态名称(如:正面全景)">
<button class="btn btn-small btn-success" @click="addPoseToMachine(selectedMachine.id, 'front')"> 添加姿态</button>
</div>
</div>
<!-- 背面点位 -->
<div class="machine-form" style="margin-top:16px">
<h3>📷 背面点位</h3>
<div class="form-row">
<div class="form-group">
<label>X 坐标</label>
<input type="number" step="0.01" v-model.number="selectedMachine.back.coords[0]" placeholder="0.00">
</div>
<div class="form-group">
<label>Y 坐标</label>
<input type="number" step="0.01" v-model.number="selectedMachine.back.coords[1]" placeholder="0.00">
</div>
<div class="form-group">
<label>Yaw (弧度)</label>
<input type="number" step="0.01" v-model.number="selectedMachine.back.coords[2]" placeholder="0.00">
</div>
<div class="form-group" style="align-self:end">
<button class="btn btn-small btn-primary" @click="readPosition('back')" :disabled="!agvConnected">📍 读取当前位置</button>
</div>
</div>
<!-- 背面姿态列表 -->
<div v-if="selectedMachine.back.poses && selectedMachine.back.poses.length > 0" class="pose-list">
<h4>背面姿态 ({% raw %}{{ selectedMachine.back.poses.length }}{% endraw %} 个)</h4>
<div v-for="pose in selectedMachine.back.poses" :key="pose.id" class="pose-item">
<span class="pose-name">{% raw %}{{ pose.name }}{% endraw %}</span>
<span class="pose-angles" v-if="pose.arm_angles">角度: {% raw %}{{ formatAngles(pose.arm_angles) }}{% endraw %}</span>
<button class="btn-icon" @click="deletePose(selectedMachine.id, 'back', pose.id)">🗑️</button>
</div>
</div>
<div class="pose-add">
<input type="text" v-model="poseForm.name" placeholder="姿态名称(如:背面细节)">
<button class="btn btn-small btn-success" @click="addPoseToMachine(selectedMachine.id, 'back')"> 添加姿态</button>
</div>
</div>
<div class="btn-row" style="margin-top:16px">
<button class="btn btn-danger" @click="deleteMachine(selectedMachine.id)">🗑️ 删除此机器</button>
<button class="btn btn-secondary" @click="saveMachineCoords">💾 保存此机器配置</button>
</div>
</section>
<!-- 下:序列预览 -->
<section class="card" v-if="sequence && sequence.length > 0" style="margin-top:16px">
<h2>③ 🐍 蛇形拍摄序列预览</h2>
<div class="sequence-preview">
<div v-for="(step, idx) in sequence" :key="idx" class="sequence-step">
<span class="step-index">{% raw %}{{ idx+1 }}{% endraw %}</span>
<span class="step-info">
第{% raw %}{{ step.row+1 }}{% endraw %}行 第{% raw %}{{ step.col+1 }}{% endraw %}列
<span class="step-side" :class="step.side">{% raw %}{{ step.side === 'front' ? '正面' : '背面' }}{% endraw %}</span>
</span>
</div>
</div>
<div class="btn-row" style="margin-top:12px">
<button class="btn btn-secondary" @click="refreshSequence">🔄 刷新序列</button>
</div>
</section>
</div>
<!-- 点位编辑弹窗(基于独立点位行模型) -->
<div v-if="editingPoint" class="modal-overlay" @click.self="closePointEdit">
<div class="modal-box" style="min-width:460px">
<div style="display:flex;align-items:center;justify-content:space-between;margin-bottom:14px">
<h3 style="margin:0">📍 点位配置 — {% raw %}{{ getPointOwnerLabel(editingPoint.pointRow, editingPoint.col) }}{% endraw %}</h3>
<button class="btn-icon" @click="closePointEdit"></button>
</div>
<div style="margin-bottom:14px">
<div class="form-row">
<div class="form-group">
<label>X</label>
<input type="number" step="0.01" v-model.number="pointEditor.x" placeholder="0.00">
</div>
<div class="form-group">
<label>Y</label>
<input type="number" step="0.01" v-model.number="pointEditor.y" placeholder="0.00">
</div>
<div class="form-group">
<label>Yaw (rad)</label>
<input type="number" step="0.01" v-model.number="pointEditor.yaw" placeholder="0.00">
</div>
</div>
<div class="hint" style="margin-top:4px">
当前: ({% raw %}{{ pointEditor.x.toFixed(2) }}{% endraw %}, {% raw %}{{ pointEditor.y.toFixed(2) }}{% endraw %}, {% raw %}{{ pointEditor.yaw.toFixed(2) }}{% endraw %})
</div>
<div class="hint" style="margin-top:6px;font-size:12px;color:#888">
💡 此点位服务于: {% raw %}{{ getPointOwnerLabel(editingPoint.pointRow, editingPoint.col).split('·')[1] || '无' }}{% endraw %}
</div>
</div>
<div class="btn-row">
<button class="btn btn-primary" @click="loadPointFromAgv" :disabled="!agvConnected">📍 从AGV读取</button>
<button class="btn btn-success" @click="savePoint">💾 保存</button>
<button class="btn btn-warning" @click="clearPoint" :disabled="canClearPoint(editingPoint.pointRow, editingPoint.col)">🗑️ 清空</button>
<button class="btn btn-secondary" @click="closePointEdit">取消</button>
</div>
</div>
</div>
<!-- 机械臂控制 (保持不变) -->
<div v-if="tab === 'arm'">
<section class="card">
<h2>🤖 机械臂控制</h2>
<div v-if="!armConnected" class="alert alert-error">
⚠️ 机械臂未连接,请先在首页连接设备
</div>
<div v-else>
<div class="camera-preview">
<img :src="previewUrl" @error="onPreviewError">
</div>
<div class="joints-panel">
<h3>关节角度控制</h3>
<div class="joint-grid">
<div v-for="j in 6" :key="j" class="joint-control">
<label>J{% raw %}{{ j }}{% endraw %}</label>
<div class="joint-value">{% raw %}{{ currentAngles[j-1] ? currentAngles[j-1].toFixed(1) : '—' }}{% endraw %}°</div>
<div class="joint-buttons">
<button @mousedown="jogStart(j-1, -1)" @mouseup="jogStop(j-1)" @mouseleave="jogStop(j-1)"></button>
<input type="number" v-model.number="angleInputs[j-1]" step="0.5" @change="setAngle(j-1, angleInputs[j-1])">
<button @mousedown="jogStart(j-1, 1)" @mouseup="jogStop(j-1)" @mouseleave="jogStop(j-1)"></button>
</div>
</div>
</div>
<div class="btn-row">
<button class="btn btn-primary" @click="refreshAngles">🔄 刷新角度</button>
<button class="btn btn-secondary" @click="applyAngles">✅ 应用角度</button>
</div>
</div>
</div>
</section>
</div>
<!-- AGV 移动控制 (保持不变) -->
<div v-if="tab === 'agv'">
<section class="card">
<h2>🚗 AGV 移动控制</h2>
<div v-if="!agvConnected" class="alert alert-error">
⚠️ AGV 未连接,请先在首页连接设备
</div>
<div v-else>
<div v-show="cameraOpened" class="camera-preview" style="margin-bottom:16px">
<img :src="agvCameraUrl" style="width:100%;max-width:480px;aspect-ratio:16/9;object-fit:cover;border-radius:8px" @error="agvCameraUrl=''">
</div>
<div class="agv-status-bar">
<span>🔋 电压: <strong>{% raw %}{{ agvBattery !== null ? agvBattery + 'V' : '—' }}{% endraw %}</strong></span>
<span v-if="agvPosition">📍 位置: <strong>X={% raw %}{{ agvPosition[0] ? agvPosition[0].toFixed(2) : '?' }}{% endraw %} Y={% raw %}{{ agvPosition[1] ? agvPosition[1].toFixed(2) : '?' }}{% endraw %}</strong></span>
<button class="btn btn-small" @click="refreshAgvPosition">🔄 刷新</button>
</div>
<div class="agv-control-panel">
<div class="agv-dir-row">
<div class="agv-dir-placeholder"></div>
<button class="agv-btn agv-btn-up" @mousedown="agvMoveStart('forward')" @mouseup="agvMoveStop" @mouseleave="agvMoveStop">⬆️ 前进</button>
<div class="agv-dir-placeholder"></div>
</div>
<div class="agv-dir-row">
<button class="agv-btn agv-btn-left" @mousedown="agvMoveStart('left')" @mouseup="agvMoveStop" @mouseleave="agvMoveStop">↺ 左转</button>
<button class="agv-btn agv-btn-stop" @click="agvStop">🛑</button>
<button class="agv-btn agv-btn-right" @mousedown="agvMoveStart('right')" @mouseup="agvMoveStop" @mouseleave="agvMoveStop">↻ 右转</button>
</div>
<div class="agv-dir-row">
<div class="agv-dir-placeholder"></div>
<button class="agv-btn agv-btn-down" @mousedown="agvMoveStart('backward')" @mouseup="agvMoveStop" @mouseleave="agvMoveStop">⬇️ 后退</button>
<div class="agv-dir-placeholder"></div>
</div>
</div>
<div class="agv-lateral-row">
<button class="agv-btn agv-btn-lateral" @mousedown="agvMoveStart('left_lateral')" @mouseup="agvMoveStop" @mouseleave="agvMoveStop">⬅️ 向左平移</button>
<button class="agv-btn agv-btn-lateral" @mousedown="agvMoveStart('right_lateral')" @mouseup="agvMoveStop" @mouseleave="agvMoveStop">向右平移 ➡️</button>
</div>
<div class="form-row" style="margin-top:16px; max-width:400px">
<div class="form-group">
<label>移动速度</label>
<div class="speed-control">
<input type="range" v-model.number="agvSpeed" min="0.1" max="1.0" step="0.1" style="flex:1">
<span class="speed-value">{% raw %}{{ (agvSpeed * 100).toFixed(0) }}{% endraw %}%</span>
</div>
</div>
</div>
<div class="btn-row" style="margin-top:12px">
<button class="btn btn-danger" @click="agvResetCollision">🔄 撞物体后复位</button>
<button class="btn btn-secondary" @click="agvStop">🛑 立即停止</button>
</div>
</div>
</section>
</div>
</main>
</div>
<script src="/static/js/vue3.global.prod.js?v=20260513b"></script>
<script src="/static/js/setting.js?v=20260514g"></script>
</body>
</html>
+587
View File
@@ -0,0 +1,587 @@
const { createApp } = Vue
const API = ''
createApp({
data() {
return {
tab: 'map',
// 任务配置
missionConfig: { rows: 3, cols: 3, grid: [], machines: [] },
selectedMachine: null,
sequence: [],
poseForm: { name: '', photo_type: 'front', description: '' },
// 地图
mapForm: { map_dir: '/home/elephant/agv_pro_ros2/src/agv_pro_navigation2/map/', map_file: 'map.yaml' },
mapMsg: '',
mapLoaded: false,
mapImageUrl: '',
mapMeta: null,
// 点位
points: [],
newPointName: '',
newPointMode: 'front',
newPointSequence: ['front', 'back'],
// 机型(姿态组)
models: [],
selectedModelId: null,
newModelName: '',
newModelSerial: '',
// 机械臂
armConnected: false,
currentAngles: [],
angleInputs: [],
previewUrl: API + '/api/camera/preview',
jogIntervals: {},
// AGV
cameraOpened: false,
agvConnected: false,
agvBattery: null,
agvPosition: null,
agvSpeed: 0.5,
agvMoveInterval: null,
agvCameraUrl: API + '/api/camera/refresh',
agvCameraTimer: null,
}
},
mounted() {
this.refresh()
this.refreshAngles()
},
watch: {
tab(val) {
if (val === 'agv') {
this.agvCameraTimer = setInterval(() => {
this.agvCameraUrl = API + '/api/camera/refresh?t=' + Date.now()
}, 1000)
} else {
if (this.agvCameraTimer) {
clearInterval(this.agvCameraTimer)
this.agvCameraTimer = null
}
}
}
},
beforeUnmount() {
Object.values(this.jogIntervals).forEach(i => clearInterval(i))
if (this.agvCameraTimer) clearInterval(this.agvCameraTimer)
},
methods: {
async refresh() {
try {
const res = await fetch(API + '/api/status')
const data = await res.json()
this.agvConnected = data.agv_connected
this.armConnected = data.arm_connected
this.cameraOpened = data.camera_opened
this.mapLoaded = data.map_loaded
if (data.map_loaded) {
this.mapImageUrl = API + '/api/map/image?t=' + Date.now()
try {
const metaRes = await fetch(API + '/api/map/meta')
const meta = await metaRes.json()
if (meta.ok) this.mapMeta = meta
} catch (e) {}
}
} catch (e) {}
await this.loadAllPoints()
await this.loadAllModels()
await this.loadAllMachines()
await this.loadMissionConfig()
},
// === 地图 ===
async loadMap() {
const res = await fetch(API + '/api/map/load', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(this.mapForm)
})
const data = await res.json()
this.mapMsg = data.ok ? '✅ 地图加载成功' : '❌ ' + (data.error || '加载失败')
this.mapLoaded = data.ok
if (data.ok) {
this.mapImageUrl = API + '/api/map/image?t=' + Date.now()
try {
const metaRes = await fetch(API + '/api/map/meta')
const meta = await metaRes.json()
if (meta.ok) this.mapMeta = meta
} catch (e) {}
}
},
onMapError() {
this.mapMsg = '❌ 地图图像加载失败'
},
getMapX(coords) {
if (!coords || !this.mapMeta) return 50
const [x, y, yaw] = coords
const { resolution, origin, width } = this.mapMeta
const px = (x - origin[0]) / (resolution * width) * 100
return Math.max(0, Math.min(100, px))
},
getMapY(coords) {
if (!coords || !this.mapMeta) return 50
const [x, y, yaw] = coords
const { resolution, origin, height } = this.mapMeta
const py = (y - origin[1]) / (resolution * height) * 100
return Math.max(0, Math.min(100, 100 - py))
},
async saveMap() {
await fetch(API + '/api/map/save', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(this.mapForm)
})
this.mapMsg = '✅ 地图配置已保存'
},
// === 点位 ===
async loadAllPoints() {
const res = await fetch(API + '/api/points/list')
const data = await res.json()
this.points = data.points || []
},
async addPoint() {
const res = await fetch(API + '/api/points/add', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
name: this.newPointName || 'point_' + (this.points.length + 1),
photo_mode: this.newPointMode,
sequence: this.newPointSequence
})
})
const data = await res.json()
if (data.ok) {
await this.loadAllPoints()
this.newPointName = ''
}
},
async deletePoint(id) {
if (!confirm('确定删除该点位?')) return
await fetch(API + '/api/points/delete/' + id, { method: 'DELETE' })
await this.loadAllPoints()
},
async saveAllPoints() {
await fetch(API + '/api/points/save', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ points: this.points })
})
alert('点位已保存')
},
getPoint(id) {
return this.points.find(p => p.id === id)
},
formatAngles(angles) {
if (!angles) return '—'
return angles.map(a => (a || 0).toFixed(1) + '°').join(' / ')
},
// === 机型管理 ===
async loadAllModels() {
const res = await fetch(API + '/api/models/list')
const data = await res.json()
this.models = data.models || []
this.models.forEach(m => {
if (!this.poseForm[m.id]) {
this.poseForm[m.id] = { name: '', photo_type: 'front', description: '' }
}
})
},
async addModel() {
const res = await fetch(API + '/api/models/add', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
name: this.newModelName || 'model_' + (this.models.length + 1),
serial_prefix: this.newModelSerial,
description: ''
})
})
const data = await res.json()
if (data.ok) {
await this.loadAllModels()
this.newModelName = ''
this.newModelSerial = ''
}
},
async deleteModel(modelId) {
if (!confirm('确定删除该机型?其下所有姿态将被删除!')) return
await fetch(API + '/api/models/delete/' + modelId, { method: 'DELETE' })
await this.loadAllModels()
},
// === 姿态管理(属于机型)===
async addPose(modelId) {
const form = this.poseForm[modelId]
if (!form) return
await fetch(API + '/api/models/poses/add', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
model_id: modelId,
name: form.name || '姿态' + ((this.getModel(modelId)?.poses?.length || 0) + 1),
photo_type: form.photo_type,
arm_angles: this.currentAngles,
speed: 500,
description: form.description || ''
})
})
await this.loadAllModels()
form.name = ''
form.description = ''
},
async deletePose(modelId, poseId) {
if (!confirm('确定删除该姿态?')) return
await fetch(API + '/api/models/' + modelId + '/poses/' + poseId, { method: 'DELETE' })
await this.loadAllModels()
},
getModel(id) {
return this.models.find(m => m.id === id)
},
// === 任务配置 ===
async loadMissionConfig() {
try {
const res = await fetch(API + '/api/mission/config')
const data = await res.json()
if (data.ok && data.config) {
this.missionConfig.rows = data.config.rows || 3
this.missionConfig.cols = data.config.cols || 3
this.missionConfig.grid = data.config.grid || []
}
} catch (e) { console.error('加载任务配置失败', e) }
},
async generateGrid() {
try {
const res = await fetch(API + '/api/mission/config', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
rows: this.missionConfig.rows,
cols: this.missionConfig.cols,
grid: []
})
})
const data = await res.json()
if (data.ok) {
this.missionConfig.grid = data.config.grid || []
alert('✅ 网格已生成 (' + this.missionConfig.rows + '×' + this.missionConfig.cols + ')')
} else {
alert('❌ 网格生成失败')
}
} catch (e) { alert('请求失败: ' + e.message) }
},
async saveMissionConfig() {
try {
const res = await fetch(API + '/api/mission/config', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
rows: this.missionConfig.rows,
cols: this.missionConfig.cols,
grid: this.missionConfig.grid
})
})
const data = await res.json()
if (data.ok) {
alert('✅ 网格配置已保存')
}
} catch (e) { alert('保存失败: ' + e.message) }
},
async loadAllMachines() {
try {
const res = await fetch(API + '/api/mission/machines')
const data = await res.json()
this.missionConfig.machines = data.machines || []
} catch (e) { console.error('加载机器列表失败', e) }
},
getMachineAt(ri, ci) {
if (!this.missionConfig.machines) return null
return this.missionConfig.machines.find(m => m.row === ri && m.col === ci) || null
},
getPositionAt(ri, ci) {
if (!this.missionConfig.machines) return null
const machine = this.getMachineAt(ri, ci)
if (!machine) return null
if (ri === 0) return machine.front
const prevMachine = this.getMachineAt(ri - 1, ci)
return prevMachine ? prevMachine.back : machine.front
},
onCellClick(ri, ci) {
const m = this.getMachineAt(ri, ci)
if (!m) {
// 无机器 → 创建机器记录并选中
this.createMachine(ri, ci).then(ok => {
if (ok) {
const created = this.getMachineAt(ri, ci)
if (created) this.selectMachine(created)
}
})
} else {
// 有机器 → 选中
this.selectMachine(m)
}
},
async createMachine(ri, ci) {
try {
const machineId = 'm_' + ri + '_' + ci
const res = await fetch(API + '/api/mission/machines/add', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
id: machineId,
row: ri,
col: ci,
front: { coords: [0, 0, 0], poses: [] },
back: { coords: [0, 0, 0], poses: [] }
})
})
const data = await res.json()
if (!data.ok && data.error !== '该位置已有机器') {
alert('创建机器失败: ' + (data.error || '未知错误'))
return false
}
await this.loadAllMachines()
return true
} catch (e) { alert('创建机器失败: ' + e.message); return false }
},
selectMachine(machine) {
if (!machine.front) machine.front = { coords: [0, 0, 0], poses: [] }
else if (!Array.isArray(machine.front.coords)) machine.front.coords = [0, 0, 0]
if (!machine.back) machine.back = { coords: [0, 0, 0], poses: [] }
else if (!Array.isArray(machine.back.coords)) machine.back.coords = [0, 0, 0]
this.selectedMachine = machine
},
clearSelection() {
this.selectedMachine = null
},
async deleteMachine(machineId) {
if (!confirm('确定删除此机器?')) return
try {
await fetch(API + '/api/mission/machines/' + machineId, { method: 'DELETE' })
this.selectedMachine = null
await this.loadAllMachines()
} catch (e) { alert('删除失败: ' + e.message) }
},
async saveMachineCoords() {
if (!this.selectedMachine) return
try {
const res = await fetch(API + '/api/mission/machines/' + this.selectedMachine.id, {
method: 'PUT',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
front: this.selectedMachine.front,
back: this.selectedMachine.back
})
})
if (res.ok) {
this.mapMsg = '✅ 机器坐标已保存'
setTimeout(() => this.mapMsg = '', 2000)
} else {
alert('保存失败: ' + res.status)
}
} catch (e) { alert('保存失败: ' + e.message) }
},
async readPosition(side) {
if (!this.agvConnected) { alert('AGV 未连接'); return }
try {
const res = await fetch(API + '/api/agv/position')
const data = await res.json()
if (data.ok && data.position) {
const [x, y, theta] = data.position
if (side === 'front') {
this.selectedMachine.front.coords = [x, y, theta]
} else {
this.selectedMachine.back.coords = [x, y, theta]
}
} else {
alert('读取位置失败: ' + (data.error || '未知错误'))
}
} catch (e) { alert('读取位置失败: ' + e.message) }
},
async addPoseToMachine(machineId, side) {
const name = this.poseForm.name || '姿态' + (((this.selectedMachine && this.selectedMachine[side] && this.selectedMachine[side].poses) || []).length + 1)
try {
const res = await fetch(API + '/api/mission/poses/' + machineId + '/' + side, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
name: name,
arm_angles: this.currentAngles.length === 6 ? this.currentAngles : [0, 0, 0, 0, 0, 0],
speed: 500,
description: ''
})
})
const data = await res.json()
if (data.ok) {
this.poseForm.name = ''
await this.loadAllMachines()
// 重新选中当前机器以刷新姿态列表
const updated = this.getMachineAt(this.selectedMachine.row, this.selectedMachine.col)
if (updated) this.selectMachine(updated)
} else {
alert('添加姿态失败: ' + (data.error || '未知错误'))
}
} catch (e) { alert('添加姿态失败: ' + e.message) }
},
async deletePose(machineId, side, poseId) {
if (!confirm('确定删除此姿态?')) return
try {
await fetch(API + '/api/mission/poses/' + machineId + '/' + side + '/' + poseId, { method: 'DELETE' })
await this.loadAllMachines()
if (this.selectedMachine) {
const updated = this.getMachineAt(this.selectedMachine.row, this.selectedMachine.col)
if (updated) this.selectMachine(updated)
}
} catch (e) { alert('删除姿态失败: ' + e.message) }
},
async capturePosition(ri, ci, side) {
if (!this.agvConnected) { alert('请先连接AGV'); return }
let machine = this.getMachineAt(ri, ci)
if (!machine) {
try {
const machineId = 'm_' + ri + '_' + ci
const res = await fetch(API + '/api/mission/machines/add', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
id: machineId,
row: ri,
col: ci,
front: { coords: [0, 0, 0], poses: [] },
back: { coords: [0, 0, 0], poses: [] }
})
})
if (!res.ok) throw new Error('创建失败')
await this.loadAllMachines()
machine = this.getMachineAt(ri, ci)
} catch (e) { alert('创建机器失败: ' + e.message); return }
}
try {
const res = await fetch(API + '/api/agv/position')
const pos = await res.json()
let x = 0, y = 0, theta = 0
if (pos.ok && pos.position && Array.isArray(pos.position)) {
x = pos.position[0] || 0
y = pos.position[1] || 0
theta = pos.position[2] || 0
} else {
alert('读取位置失败: ' + (pos.error || '未知错误'))
return
}
if (!machine) { machine = this.getMachineAt(ri, ci) }
if (!machine) { alert('机器记录不存在'); return }
if (side === 'front') { machine.front.coords = [x, y, theta] } else { machine.back.coords = [x, y, theta] }
await fetch(API + '/api/mission/machines/' + machine.id, {
method: 'PUT',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(machine)
})
alert((side === 'front' ? '正面' : '背面') + '点位已更新: (' + x.toFixed(2) + ',' + y.toFixed(2) + ',' + theta.toFixed(2) + ')')
} catch (e) { alert('读取位置失败: ' + e.message) }
},
async refreshSequence() {
try {
const res = await fetch(API + '/api/mission/generate_sequence')
const data = await res.json()
if (data.ok) {
this.sequence = data.sequence || []
}
} catch (e) { console.error('刷新序列失败', e) }
},
// === 机械臂 ===
async refreshAngles() {
if (!this.armConnected) return
try {
const res = await fetch(API + '/api/arm/get_angles')
const data = await res.json()
if (data.ok && data.angles) {
this.currentAngles = data.angles
this.angleInputs = [...data.angles]
}
} catch (e) {}
},
async setAngle(idx, val) {
await fetch(API + '/api/arm/set_angle', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ joint: 'J' + (idx + 1), angle: val })
})
},
async applyAngles() {
await fetch(API + '/api/arm/set_angles', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ angles: this.angleInputs, speed: 500 })
})
},
jogStart(idx, dir) {
const joint = 'J' + (idx + 1)
fetch(API + '/api/arm/jog', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ joint, direction: dir })
})
this.jogIntervals[idx] = setInterval(() => this.refreshAngles(), 200)
},
jogStop(idx) {
clearInterval(this.jogIntervals[idx])
const joint = 'J' + (idx + 1)
fetch(API + '/api/arm/jog', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ joint, direction: 0 })
})
setTimeout(() => this.refreshAngles(), 300)
},
onPreviewError(e) {
e.target.style.display = 'none'
},
// === AGV 控制 ===
async refreshAgvPosition() {
if (!this.agvConnected) return
try {
const res = await fetch(API + '/api/agv/position')
const data = await res.json()
if (data.ok) {
this.agvPosition = data.position
this.agvBattery = data.battery
}
} catch (e) {}
},
agvMoveStart(dir) {
if (!this.agvConnected) return
fetch(API + '/api/agv/move', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ direction: dir, speed: this.agvSpeed })
})
},
agvMoveStop() {
fetch(API + '/api/agv/move', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ direction: 'stop' })
})
},
async agvStop() {
await fetch(API + '/api/agv/stop', { method: 'POST' })
},
async agvResetCollision() {
if (!this.agvConnected) {
alert('AGV 未连接')
return
}
if (!confirm('确定执行撞物体后复位?')) return
try {
const res = await fetch(API + '/api/agv/reset', { method: 'POST' })
const data = await res.json()
if (data.ok) {
alert('✅ ' + data.message)
await this.refresh()
await this.refreshAgvPosition()
} else {
alert('❌ 复位失败: ' + (data.error || '未知错误'))
}
} catch (e) {
alert('❌ 复位请求失败: ' + e.message)
}
},
}
}).mount('#app')
+5
View File
@@ -0,0 +1,5 @@
#!/bin/bash
# 启动 AGV 拍摄系统
cd ~/work/agv_app
python3 app.py
+46
View File
@@ -0,0 +1,46 @@
#!/bin/bash
# AGV 拍摄系统完整启动脚本 - ROS2 + Flask
# 使用方法: ./start_all.sh
echo "=== 停止旧进程 ==="
pkill -f "ros2 launch agv_pro_bringup" 2>/dev/null
pkill -f "python.*app.py" 2>/dev/null
sleep 2
echo "=== 启动 ROS2 Bringup ==="
# Source ROS2 环境
source /opt/ros/humble/setup.bash
cd /home/elephant/agv_pro_ros2
source install/setup.bash
# 启动 ROS2 bringup (后台运行)
nohup ros2 launch agv_pro_bringup agv_pro_bringup.launch.py port_name:=/dev/agvpro_controller > /tmp/ros2_bringup.log 2>&1 &
ROS2_PID=$!
echo "ROS2 bringup started, PID: $ROS2_PID"
# 等待 ROS2 初始化 (AGV节点需要连接串口)
echo "等待 ROS2 初始化..."
sleep 5
# 检查 ROS2 节点是否启动
if source /opt/ros/humble/setup.bash && source ~/agv_pro_ros2/install/setup.bash && ros2 node list 2>/dev/null | grep -q agv_pro_node; then
echo "✅ ROS2 AGV 节点已启动"
else
echo "⚠️ ROS2 节点启动可能失败,请检查日志: /tmp/ros2_bringup.log"
fi
echo "=== 启动 Flask ==="
cd /home/elephant/work/agv_app
nohup python3 app.py > /tmp/agv_flask.log 2>&1 &
FLASK_PID=$!
echo "Flask started, PID: $FLASK_PID"
sleep 2
echo ""
echo "=== 启动完成 ==="
echo "ROS2 log: /tmp/ros2_bringup.log"
echo "Flask log: /tmp/agv_flask.log"
echo ""
echo "检查状态:"
echo " ros2 node list"
echo " curl http://localhost:5000/api/status"
+9
View File
@@ -0,0 +1,9 @@
#!/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: $!"
+722
View File
@@ -0,0 +1,722 @@
/* ========== 全局样式 ========== */
* { box-sizing: border-box; margin: 0; padding: 0; }
body {
font-family: -apple-system, "PingFang SC", "Microsoft YaHei", sans-serif;
background: #0f1923;
color: #e8eaed;
font-size: 14px;
min-height: 100vh;
}
a { color: #4fc3f7; text-decoration: none; }
a:hover { text-decoration: underline; }
.container {
max-width: 1200px;
margin: 0 auto;
padding: 20px 16px;
display: grid;
grid-template-columns: 1fr 1fr;
gap: 16px;
}
/* ========== 顶部栏 ========== */
.topbar {
background: #1a2332;
border-bottom: 1px solid #2a3441;
padding: 0 20px;
display: flex;
align-items: center;
height: 56px;
gap: 32px;
position: sticky;
top: 0;
z-index: 100;
}
.logo { font-size: 18px; font-weight: bold; color: #4fc3f7; }
.nav { display: flex; gap: 4px; }
.nav-link {
padding: 8px 16px;
border-radius: 6px;
color: #9aa0a6;
transition: all 0.2s;
}
.nav-link:hover { background: #2a3441; color: #e8eaed; text-decoration: none; }
.nav-link.active { background: #263238; color: #4fc3f7; }
.status-bar { margin-left: auto; display: flex; gap: 12px; }
.status-item {
padding: 4px 12px;
border-radius: 12px;
font-size: 12px;
font-weight: bold;
}
.status-item.setting { background: #1b3a2f; color: #4caf50; }
.status-item.running { background: #2a2a1b; color: #ffeb3b; }
.status-item.paused { background: #3a2a1a; color: #ff9800; }
.status-item.idle { background: #2a2a2a; color: #9aa0a6; }
/* ========== 卡片 ========== */
.card {
background: #1a2332;
border-radius: 12px;
padding: 20px;
border: 1px solid #2a3441;
}
.card h2 { font-size: 16px; margin-bottom: 16px; color: #4fc3f7; }
/* ========== 状态卡片 ========== */
.grid-3 { display: grid; grid-template-columns: repeat(3, 1fr); gap: 12px; margin-bottom: 16px; }
.status-card {
background: #0f1923;
border-radius: 8px;
padding: 16px;
text-align: center;
border: 1px solid #2a3441;
}
.status-card.ok { border-color: #2e7d32; background: #0d1f14; }
.status-card.error { border-color: #c62828; background: #1f0d0d; }
.status-icon { font-size: 24px; margin-bottom: 8px; }
.status-label { font-size: 12px; color: #9aa0a6; margin-bottom: 4px; }
.status-value { font-size: 14px; font-weight: bold; }
/* ========== 按钮 ========== */
.btn {
padding: 8px 16px;
border-radius: 6px;
border: none;
cursor: pointer;
font-size: 14px;
transition: all 0.2s;
background: #263238;
color: #e8eaed;
font-family: inherit;
}
.btn:hover:not(:disabled) { background: #37474f; }
.btn:disabled { opacity: 0.5; cursor: not-allowed; }
.btn-primary { background: #0277bd; color: #fff; }
.btn-primary:hover:not(:disabled) { background: #0288d1; }
.btn-secondary { background: #37474f; }
.btn-danger { background: #d32f2f; color: #fff; }
.btn-danger:hover:not(:disabled) { background: #f44336; }
.btn-success { background: #2e7d32; color: #fff; }
.btn-success:hover:not(:disabled) { background: #388e3c; }
.btn-warning { background: #e65100; color: #fff; }
.btn-error { background: #c62828; color: #fff; }
.btn-large { padding: 12px 24px; font-size: 16px; }
.btn-small { padding: 4px 10px; font-size: 12px; }
.btn-icon { background: none; border: none; cursor: pointer; font-size: 14px; padding: 4px; }
.btn-row { display: flex; gap: 8px; margin-top: 12px; flex-wrap: wrap; }
/* ========== 表单 ========== */
.form-group { margin-bottom: 12px; }
.form-group label { display: block; font-size: 12px; color: #9aa0a6; margin-bottom: 4px; }
.form-group input,
.form-group select {
width: 100%;
padding: 8px 12px;
background: #0f1923;
border: 1px solid #2a3441;
border-radius: 6px;
color: #e8eaed;
font-size: 14px;
font-family: inherit;
}
.form-group input:focus,
.form-group select:focus { outline: none; border-color: #4fc3f7; }
.form-row { display: grid; grid-template-columns: 1fr 1fr; gap: 12px; }
/* ========== Tabs ========== */
.tabs {
background: #1a2332;
border-bottom: 1px solid #2a3441;
padding: 0 20px;
display: flex;
gap: 4px;
}
.tab {
padding: 12px 20px;
background: none;
border: none;
color: #9aa0a6;
cursor: pointer;
font-size: 14px;
border-bottom: 2px solid transparent;
font-family: inherit;
}
.tab.active { color: #4fc3f7; border-bottom-color: #4fc3f7; }
.tab:hover { color: #e8eaed; }
/* ========== 摄像头预览 ========== */
.camera-preview {
width: 100%;
max-width: 480px;
border-radius: 8px;
overflow: hidden;
margin: 0 auto 16px;
background: #000;
}
.camera-preview img,
.camera-full img {
width: 100%;
display: block;
aspect-ratio: 16/9;
object-fit: cover;
}
.camera-full {
width: 100%;
border-radius: 8px;
overflow: hidden;
background: #000;
}
/* ========== 关节控制 ========== */
.joints-panel { margin-top: 16px; }
.joints-panel h3 { margin-bottom: 12px; font-size: 14px; }
.joint-grid { display: grid; grid-template-columns: repeat(3, 1fr); gap: 12px; }
.joint-control {
background: #0f1923;
border-radius: 8px;
padding: 12px;
text-align: center;
border: 1px solid #2a3441;
}
.joint-control label { font-size: 12px; color: #4fc3f7; font-weight: bold; }
.joint-value { font-size: 18px; font-weight: bold; color: #fff; margin: 4px 0; }
.joint-buttons { display: flex; align-items: center; gap: 4px; justify-content: center; }
.joint-buttons button {
width: 32px;
height: 32px;
border-radius: 4px;
border: 1px solid #2a3441;
background: #263238;
color: #e8eaed;
cursor: pointer;
font-size: 14px;
}
.joint-buttons input {
width: 60px;
padding: 4px;
text-align: center;
background: #0f1923;
border: 1px solid #2a3441;
border-radius: 4px;
color: #e8eaed;
font-size: 12px;
}
/* ========== 点位列表 ========== */
.point-item {
background: #0f1923;
border: 1px solid #2a3441;
border-radius: 8px;
padding: 12px;
margin-bottom: 12px;
}
.point-header { display: flex; align-items: center; gap: 8px; margin-bottom: 8px; }
.point-name { font-weight: bold; font-size: 15px; }
.point-coords { font-size: 12px; color: #9aa0a6; margin-bottom: 8px; }
.badge {
padding: 2px 8px;
border-radius: 10px;
font-size: 11px;
background: #263238;
color: #4fc3f7;
}
.pose-list { margin-top: 8px; }
.pose-item {
display: flex;
align-items: center;
gap: 8px;
padding: 6px 0;
border-bottom: 1px solid #2a3441;
font-size: 13px;
}
.angles { color: #9aa0a6; font-size: 11px; font-family: monospace; }
.pose-add {
display: flex;
gap: 8px;
align-items: center;
margin-top: 8px;
}
.pose-add input { flex: 1; padding: 6px 10px; background: #1a2332; border: 1px solid #2a3441; border-radius: 4px; color: #e8eaed; font-size: 13px; }
.pose-add select { padding: 6px; background: #1a2332; border: 1px solid #2a3441; border-radius: 4px; color: #e8eaed; }
.empty-hint { color: #9aa0a6; text-align: center; padding: 20px; }
.hint { font-size: 12px; color: #9aa0a6; margin-top: 8px; }
.alert { padding: 12px 16px; border-radius: 8px; margin-bottom: 12px; }
.alert-error { background: #1f0d0d; border: 1px solid #c62828; color: #ef5350; }
.checkbox-group { display: flex; gap: 16px; }
.checkbox-group label { display: flex; align-items: center; gap: 6px; cursor: pointer; color: #e8eaed; }
/* ========== 运行页面 ========== */
.running-header { display: flex; align-items: center; gap: 20px; margin-bottom: 16px; }
.running-status {
font-size: 18px;
font-weight: bold;
display: flex;
align-items: center;
gap: 8px;
}
.running-status.idle { color: #9aa0a6; }
.running-status.running { color: #4caf50; }
.running-status.paused { color: #ff9800; }
.pulse {
width: 10px;
height: 10px;
border-radius: 50%;
background: currentColor;
animation: pulse 1.5s infinite;
}
@keyframes pulse {
0%, 100% { opacity: 1; transform: scale(1); }
50% { opacity: 0.5; transform: scale(0.8); }
}
.running-progress { flex: 1; display: flex; align-items: center; gap: 12px; }
.progress-bar { flex: 1; height: 8px; background: #2a3441; border-radius: 4px; overflow: hidden; }
.progress-fill { height: 100%; background: #4fc3f7; border-radius: 4px; transition: width 0.3s; }
/* ========== 报告 ========== */
.report-summary { display: flex; gap: 16px; margin-bottom: 16px; }
.stat { padding: 8px 16px; border-radius: 8px; background: #0f1923; border: 1px solid #2a3441; }
.stat.ok { border-color: #2e7d32; color: #4caf50; }
.stat.error { border-color: #c62828; color: #ef5350; }
.report-item { padding: 8px 12px; background: #0f1923; border-radius: 6px; margin-bottom: 8px; border: 1px solid #2a3441; }
.report-point { display: flex; align-items: center; gap: 8px; font-weight: bold; }
.report-status { font-size: 16px; }
.report-pose { font-size: 12px; color: #9aa0a6; padding-left: 24px; margin-top: 4px; }
/* ========== 响应式 ========== */
@media (max-width: 768px) {
.container { grid-template-columns: 1fr; }
.grid-3 { grid-template-columns: 1fr; }
.form-row { grid-template-columns: 1fr; }
.joint-grid { grid-template-columns: repeat(2, 1fr); }
.form-row { grid-template-columns: 1fr; }
}
/* AGV 移动控制面板 */
.agv-status-bar {
display: flex;
gap: 16px;
align-items: center;
padding: 10px 14px;
background: #0f1923;
border-radius: 8px;
margin-bottom: 16px;
font-size: 13px;
color: #9aa0a6;
flex-wrap: wrap;
}
.agv-status-bar strong { color: #e8eaed; }
.agv-control-panel {
display: flex;
flex-direction: column;
align-items: center;
gap: 6px;
max-width: 280px;
margin: 0 auto;
}
.agv-dir-row {
display: grid;
grid-template-columns: 80px 80px 80px;
gap: 6px;
width: 100%;
}
.agv-dir-placeholder { width: 80px; height: 44px; }
.agv-btn {
height: 44px;
border-radius: 8px;
border: 1px solid #2a3441;
background: #263238;
color: #e8eaed;
cursor: pointer;
font-size: 13px;
font-family: inherit;
transition: background 0.15s;
display: flex;
align-items: center;
justify-content: center;
gap: 4px;
user-select: none;
}
.agv-btn:active, .agv-btn:focus { outline: none; }
.agv-btn-up { background: #1b3a2f; border-color: #2e7d32; color: #4caf50; }
.agv-btn-down { background: #3a1b1b; border-color: #7d2e2e; color: #f44336; }
.agv-btn-left { background: #1b2d3a; border-color: #1565c0; color: #42a5f5; }
.agv-btn-right { background: #2d2a1b; border-color: #7d6e2e; color: #ffc107; }
.agv-btn-stop { background: #37474f; border-color: #546e7a; }
.agv-btn-up:active { background: #1e4d38; }
.agv-btn-down:active { background: #4d2020; }
.agv-btn-left:active { background: #1e3a4d; }
.agv-btn-right:active { background: #3d3820; }
.agv-btn-stop:active { background: #455a64; }
.agv-btn-lateral {
background: #2d1b4a;
border-color: #7c4dff;
color: #b388ff;
font-size: 13px;
min-width: 120px;
}
.agv-btn-lateral:active { background: #3d2560; }
.agv-lateral-row {
display: flex;
gap: 12px;
justify-content: center;
margin-top: 8px;
max-width: 280px;
width: 100%;
}
.speed-control {
display: flex;
align-items: center;
gap: 10px;
}
.speed-value {
min-width: 44px;
text-align: right;
font-weight: bold;
color: #4fc3f7;
}
/* 双摄像头预览布局 */
.camera-row {
display: grid;
grid-template-columns: 1fr 1fr;
gap: 16px;
margin-top: 12px;
}
.camera-box {
background: #111;
border-radius: 8px;
overflow: hidden;
}
.camera-label {
padding: 8px 12px;
font-size: 13px;
color: #aaa;
background: #1a1a1a;
border-bottom: 1px solid #333;
}
.camera-img {
width: 100%;
display: block;
aspect-ratio: 4/3;
object-fit: cover;
}
.camera-placeholder {
width: 100%;
aspect-ratio: 4/3;
display: flex;
align-items: center;
justify-content: center;
color: #666;
font-size: 14px;
}
/* ========== 地图标记 ========== */
.map-marker {
position: absolute;
transform: translate(-50%, -100%);
font-size: 20px;
cursor: pointer;
filter: drop-shadow(0 2px 4px rgba(0,0,0,0.5));
z-index: 10;
}
.map-marker:hover {
transform: translate(-50%, -100%) scale(1.2);
}
/* ========== 任务配置 M×N 网格 ========== */
.mission-grid-wrap {
margin-top: 12px;
overflow-x: auto;
}
.mission-grid {
display: grid;
gap: 4px;
grid-template-columns: 80px repeat(var(--cols,4), 90px);
}
.grid-cell {
min-width: 80px;
min-height: 48px;
display: flex;
align-items: center;
justify-content: center;
border-radius: 6px;
font-size: 12px;
cursor: pointer;
border: 1px solid #2a3441;
background: #0f1923;
transition: background 0.15s, border-color 0.15s;
}
.grid-cell.active {
background: #1b3a2f;
border-color: #2e7d32;
color: #4caf50;
}
.grid-cell.active:hover {
background: #234;
}
.grid-cell.selected {
border-color: #4fc3f7 !important;
box-shadow: 0 0 0 2px #4fc3f7;
}
.grid-header {
background: transparent;
border-color: transparent;
cursor: default;
font-weight: bold;
color: #9aa0a6;
font-size: 12px;
}
/* 机器配置表单 */
.machine-form {
background: #0f1923;
border: 1px solid #2a3441;
border-radius: 8px;
padding: 16px;
margin-top: 12px;
}
.machine-form h3 {
font-size: 14px;
color: #4fc3f7;
margin-bottom: 10px;
}
.machine-form h4 {
font-size: 13px;
color: #9aa0a6;
margin: 8px 0 6px;
}
/* 姿态列表 */
.pose-list {
margin-top: 8px;
}
.pose-item {
display: flex;
align-items: center;
gap: 8px;
padding: 6px 0;
border-bottom: 1px solid #2a3441;
font-size: 13px;
}
.pose-name {
font-weight: bold;
min-width: 80px;
}
.pose-angles {
color: #9aa0a6;
font-size: 11px;
font-family: monospace;
flex: 1;
}
.pose-add {
display: flex;
gap: 8px;
align-items: center;
margin-top: 8px;
}
.pose-add input {
flex: 1;
padding: 6px 10px;
background: #1a2332;
border: 1px solid #2a3441;
border-radius: 4px;
color: #e8eaed;
font-size: 13px;
}
/* 蛇形序列预览 */
.sequence-preview {
display: flex;
flex-direction: column;
gap: 4px;
max-height: 320px;
overflow-y: auto;
}
.sequence-step {
display: flex;
align-items: center;
gap: 10px;
padding: 6px 12px;
background: #0f1923;
border-radius: 6px;
border: 1px solid #2a3441;
font-size: 13px;
}
.step-index {
background: #263238;
color: #4fc3f7;
border-radius: 10px;
min-width: 28px;
height: 22px;
display: flex;
align-items: center;
justify-content: center;
font-size: 11px;
font-weight: bold;
}
.step-info {
flex: 1;
}
.step-side {
padding: 2px 8px;
border-radius: 8px;
font-size: 11px;
font-weight: bold;
}
.step-side:contains('正面') {
background: #1b3a2f;
color: #4caf50;
}
.step-side:contains('背面') {
background: #3a1b2f;
color: #ce93d8;
}
/* 网格单元格点位配置 */
.cell-machine {
font-size: 11px;
font-weight: bold;
color: #2c3e50;
}
.cell-points {
margin-top: 2px;
font-size: 9px;
}
.point-row {
display: flex;
align-items: center;
gap: 2px;
padding: 1px 2px;
background: #f8f9fa;
border-radius: 3px;
cursor: pointer;
margin: 1px 0;
}
.point-row:hover {
background: #e9ecef;
}
.point-label {
color: #666;
min-width: 24px;
}
.point-coords {
color: #0366d6;
font-family: monospace;
font-size: 8px;
flex: 1;
}
.btn-icon-small {
background: none;
border: none;
cursor: pointer;
font-size: 10px;
padding: 1px 3px;
border-radius: 3px;
}
.btn-icon-small:hover {
background: #ddd;
}
/* ========== 任务配置 弹窗 + 网格增强样式 ========== */
/* 弹窗遮罩 */
.modal-overlay {
position: fixed;
inset: 0;
background: rgba(0,0,0,0.65);
display: flex;
align-items: center;
justify-content: center;
z-index: 1000;
}
.modal-box {
background: #1a1f2e;
border: 1px solid #2a3a50;
border-radius: 12px;
padding: 20px 24px;
min-width: 380px;
max-width: 500px;
box-shadow: 0 8px 32px rgba(0,0,0,0.5);
}
.modal-box h3 { margin: 0 0 8px; color: #e0e6f0; font-size: 16px; }
/* 点位行单元格 */
.point-cell { cursor: pointer; flex-direction: column; gap: 2px; }
.point-cell:hover { border-color: #4fc3f7; background: #162030; }
.point-cell.point-filled { background: #0d2535; border-color: #1565c0; }
.point-coords { font-size: 10px; color: #64b5f6; font-family: monospace; }
.point-empty { font-size: 10px; color: #455a64; }
/* 机器行单元格 */
.machine-cell { cursor: pointer; }
.machine-cell:hover { border-color: #4caf50; background: #1b3a2f; }
.machine-cell.active { background: #1b3a2f; border-color: #2e7d32; color: #4caf50; }
.machine-icon { font-size: 18px; }
.machine-empty { font-size: 16px; color: #455a64; }
/* ========== 任务配置 弹窗 + 网格增强样式 ========== */
/* 弹窗遮罩 */
.modal-overlay {
position: fixed;
inset: 0;
background: rgba(0,0,0,0.65);
display: flex;
align-items: center;
justify-content: center;
z-index: 1000;
}
.modal-box {
background: #1a1f2e;
border: 1px solid #2a3a50;
border-radius: 12px;
padding: 20px 24px;
min-width: 380px;
max-width: 500px;
box-shadow: 0 8px 32px rgba(0,0,0,0.5);
}
.modal-box h3 { margin: 0 0 8px; color: #e0e6f0; font-size: 16px; }
/* 点位行单元格 */
.point-cell { cursor: pointer; flex-direction: column; gap: 2px; }
.point-cell:hover { border-color: #4fc3f7; background: #162030; }
.point-cell.point-filled { background: #0d2535; border-color: #1565c0; }
.point-coords { font-size: 10px; color: #64b5f6; font-family: monospace; }
.point-empty { font-size: 10px; color: #455a64; }
/* 机器行单元格 */
.machine-cell { cursor: pointer; }
.machine-cell:hover { border-color: #4caf50; background: #1b3a2f; }
.machine-cell.active { background: #1b3a2f; border-color: #2e7d32; color: #4caf50; }
.machine-icon { font-size: 18px; }
.machine-empty { font-size: 16px; color: #455a64; }
/* 点位编辑弹窗 */
.modal-overlay .modal-box { min-width: 420px; }
.modal-overlay .form-row { gap: 8px; }
.modal-overlay .btn-row { gap: 8px; flex-wrap: wrap; }
/* 地图坐标点覆盖层 */
.map-container { position: relative; }
.map-overlay {
position: absolute; top: 0; left: 0; right: 0; bottom: 0;
pointer-events: none; z-index: 10;
}
.map-dot {
position: absolute;
transform: translate(-50%, -50%);
}
.point-dot {
width: 10px; height: 10px;
background: #f39c12;
border-radius: 50%;
border: 2px solid #fff;
box-shadow: 0 0 6px rgba(243,156,18,0.9);
}
+121
View File
@@ -0,0 +1,121 @@
const { createApp } = Vue
const API = ''
createApp({
delimiters: ['[[', ']]'],
data() {
return {
connecting: false,
agvConnected: false,
armConnected: false,
cameraOpened: false,
armCameraOpened: false,
mapLoaded: false,
mapConfig: {},
pointsCount: 0,
currentState: 'idle',
// 摄像头轮询
agvCameraSrc: '/api/camera/refresh?t=' + Date.now(),
armCameraSrc: '/api/camera/arm_refresh?t=' + Date.now(),
agvCameraError: false,
armCameraError: false,
reconnectingDevice: null
}
},
computed: {
allReady() {
return this.agvConnected && this.armConnected && this.cameraOpened && this.mapLoaded
},
statusClass() {
return this.currentState
},
statusText() {
const map = { idle: '空闲', setting: '设置模式', running: '运行中', paused: '已暂停' }
return map[this.currentState] || '未知'
}
},
mounted() {
this.refresh()
setInterval(this.refreshStatus, 3000)
this.refreshCams()
setInterval(() => this.refreshCams(), 2000)
},
methods: {
refreshCams() {
this.agvCameraSrc = '/api/camera/refresh?t=' + Date.now()
this.armCameraSrc = '/api/camera/arm_refresh?t=' + Date.now()
},
async refresh() {
await this.refreshStatus()
await this.loadPoints()
},
async refreshStatus() {
try {
const res = await fetch(API + '/api/status')
const data = await res.json()
this.agvConnected = data.agv_connected
this.armConnected = data.arm_connected
this.cameraOpened = data.camera_opened
this.armCameraOpened = data.arm_camera_opened
this.mapLoaded = data.map_loaded
this.currentState = data.state || 'idle'
if (data.map_loaded && data.map) {
this.mapConfig = data.map
}
} catch (e) {
console.error(e)
}
},
async loadPoints() {
try {
const res = await fetch(API + '/api/points/list')
const data = await res.json()
this.pointsCount = data.points ? data.points.length : 0
} catch (e) {}
},
async connectAll() {
this.connecting = true
try {
const res = await fetch(API + '/api/system/connect', { method: 'POST' })
const data = await res.json()
if (data.errors && data.errors.length) {
alert('部分连接失败:\n' + data.errors.join('\n'))
}
await this.refreshStatus()
} finally {
this.connecting = false
}
},
async disconnectAll() {
await fetch(API + '/api/system/disconnect', { method: 'POST' })
await this.refreshStatus()
},
async connectDevice(device) {
if (this.connecting) return
this.reconnectingDevice = device
try {
const res = await fetch(API + '/api/device/connect', {
method: 'POST',
headers: {'Content-Type': 'application/json'},
body: JSON.stringify({device})
})
const data = await res.json()
if (!data.ok && data.error) {
alert(data.device + ' 重连失败: ' + data.error)
}
await this.refreshStatus()
} finally {
this.reconnectingDevice = null
}
},
async goRunning() {
if (!this.allReady) {
alert('请先连接所有设备并加载地图')
} else {
window.location.href = '/running'
}
}
}
}).mount('#app')
+80
View File
@@ -0,0 +1,80 @@
const { createApp } = Vue
const API = ''
createApp({
delimiters: ['[[', ']]'],
data() {
return {
missionState: 'idle',
currentPoint: 0,
totalPoints: 0,
report: null,
previewUrl: API + '/api/camera/preview',
polling: null
}
},
computed: {
missionStateText() {
const map = { idle: '空闲', running: '任务运行中', paused: '已暂停', completed: '已完成' }
return map[this.missionState] || '未知'
},
progressPercent() {
if (!this.totalPoints) return 0
return Math.round((this.currentPoint / this.totalPoints) * 100)
}
},
mounted() {
this.poll()
},
beforeUnmount() {
if (this.polling) clearInterval(this.polling)
},
methods: {
poll() {
this.refresh()
this.polling = setInterval(this.refresh, 2000)
},
async refresh() {
try {
const res = await fetch(API + '/api/mission/state')
const data = await res.json()
this.missionState = data.state || 'idle'
if (this.missionState === 'running') {
const reportRes = await fetch(API + '/api/mission/report')
const reportData = await reportRes.json()
if (reportData.report) {
this.totalPoints = reportData.report.total_points || 0
this.currentPoint = reportData.report.details?.length || 0
this.report = reportData.report
}
} else if (this.missionState === 'idle') {
const reportRes = await fetch(API + '/api/mission/report')
const reportData = await reportRes.json()
if (reportData.report) {
this.report = reportData.report
this.totalPoints = reportData.report.total_points || 0
this.currentPoint = reportData.report.details?.length || 0
}
}
} catch (e) {}
},
async startMission() {
if (this.missionState !== 'idle') return
await fetch(API + '/api/mission/start', { method: 'POST' })
this.missionState = 'running'
},
async pauseMission() {
await fetch(API + '/api/mission/pause', { method: 'POST' })
this.missionState = 'paused'
},
async stopMission() {
await fetch(API + '/api/mission/stop', { method: 'POST' })
this.missionState = 'idle'
},
onPreviewError(e) {
e.target.style.display = 'none'
}
}
}).mount('#app')
+887
View File
@@ -0,0 +1,887 @@
const { createApp } = Vue
const API = ''
const app = createApp({
data() {
return {
tab: 'map',
// 任务配置
missionConfig: { rows: 3, cols: 3, grid: [], machines: [], positions: [] },
// 点位编辑弹窗
editingPoint: null, // 当前编辑的点位 {pointRow, col} — pointRow是点位行号(0~rows)
pointEditor: { x: 0, y: 0, yaw: 0 },
selectedMachine: null,
sequence: [],
poseForm: { name: '', photo_type: 'front', description: '' },
// 地图
mapForm: { map_dir: '/home/elephant/agv_pro_ros2/src/agv_pro_navigation2/map/', map_file: 'map.yaml' },
mapMsg: '',
mapLoaded: false,
mapImageUrl: '',
mapMeta: null,
mapVersion: 0, // 地图点位版本号,用于强制重新渲染
// 点位
points: [],
newPointName: '',
newPointMode: 'front',
newPointSequence: ['front', 'back'],
// 机型(姿态组)
models: [],
selectedModelId: null,
newModelName: '',
newModelDesc: '',
newModelNotes: '',
newPoseForm: {}, // 机型配置:新建姿态的表单
// 机械臂
armConnected: false,
currentAngles: [],
angleInputs: [],
previewUrl: API + '/api/camera/preview',
jogIntervals: {},
// AGV
cameraOpened: false,
agvConnected: false,
agvBattery: null,
agvPosition: null,
agvSpeed: 0.5,
agvMoveInterval: null,
agvCameraUrl: API + '/api/camera/refresh',
agvCameraTimer: null,
}
},
mounted() {
this.refresh()
this.refreshAngles()
},
watch: {
// 监听点位数据变化,自动刷新地图
'missionConfig.positions'() {
this.mapVersion++
},
tab(val) {
if (val === 'agv') {
this.agvCameraTimer = setInterval(() => {
this.agvCameraUrl = API + '/api/camera/refresh?t=' + Date.now()
}, 1000)
} else {
if (this.agvCameraTimer) {
clearInterval(this.agvCameraTimer)
this.agvCameraTimer = null
}
}
}
},
beforeUnmount() {
Object.values(this.jogIntervals).forEach(i => clearInterval(i))
if (this.agvCameraTimer) clearInterval(this.agvCameraTimer)
},
methods: {
async refresh() {
try {
const res = await fetch(API + '/api/status')
const data = await res.json()
this.agvConnected = data.agv_connected
this.armConnected = data.arm_connected
this.cameraOpened = data.camera_opened
this.mapLoaded = data.map_loaded
if (data.map_loaded) {
this.mapImageUrl = API + '/api/map/image?t=' + Date.now()
try {
const metaRes = await fetch(API + '/api/map/meta')
const meta = await metaRes.json()
if (meta.ok) this.mapMeta = meta
} catch (e) {}
}
} catch (e) {}
await this.loadAllPoints()
await this.loadAllModels()
await this.loadAllMachines()
await this.loadMissionConfig()
},
// === 地图 ===
async loadMap() {
const res = await fetch(API + '/api/map/load', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(this.mapForm)
})
const data = await res.json()
this.mapMsg = data.ok ? '✅ 地图加载成功' : '❌ ' + (data.error || '加载失败')
this.mapLoaded = data.ok
if (data.ok) {
this.mapImageUrl = API + '/api/map/image?t=' + Date.now()
try {
const metaRes = await fetch(API + '/api/map/meta')
const meta = await metaRes.json()
if (meta.ok) this.mapMeta = meta
} catch (e) {}
}
},
onMapError() {
this.mapMsg = '❌ 地图图像加载失败'
},
getMapX(coords) {
if (!coords || !this.mapMeta) {
console.log('[getMapX] mapMeta not loaded, returning default 50');
return 50
}
const [x, y, yaw] = coords
const { resolution, origin, width } = this.mapMeta
const px = (x - origin[0]) / (resolution * width) * 100
const result = Math.max(0, Math.min(100, px));
console.log('[getMapX]', coords, '→ px%:', result);
return result
},
getMapY(coords) {
if (!coords || !this.mapMeta) {
console.log('[getMapY] mapMeta not loaded, returning default 50');
return 50
}
const [x, y, yaw] = coords
const { resolution, origin, height } = this.mapMeta
const py = (y - origin[1]) / (resolution * height) * 100
const result = Math.max(0, Math.min(100, 100 - py));
console.log('[getMapY]', coords, '→ py%:', result);
return result
},
async saveMap() {
await fetch(API + '/api/map/save', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(this.mapForm)
})
this.mapMsg = '✅ 地图配置已保存'
},
// === 点位 ===
async loadAllPoints() {
const res = await fetch(API + '/api/points/list')
const data = await res.json()
this.points = data.points || []
},
async addPoint() {
const res = await fetch(API + '/api/points/add', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
name: this.newPointName || 'point_' + (this.points.length + 1),
photo_mode: this.newPointMode,
sequence: this.newPointSequence
})
})
const data = await res.json()
if (data.ok) {
await this.loadAllPoints()
this.newPointName = ''
}
},
async deletePoint(id) {
if (!confirm('确定删除该点位?')) return
await fetch(API + '/api/points/delete/' + id, { method: 'DELETE' })
await this.loadAllPoints()
},
async saveAllPoints() {
await fetch(API + '/api/points/save', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ points: this.points })
})
alert('点位已保存')
},
getPoint(id) {
return this.points.find(p => p.id === id)
},
formatAngles(angles) {
if (!angles) return '—'
return angles.map(a => (a || 0).toFixed(1) + '°').join(' / ')
},
// === 机型管理 ===
async loadAllModels() {
const res = await fetch(API + '/api/models/list')
const data = await res.json()
this.models = data.models || []
this.models.forEach(m => {
if (!this.poseForm[m.id]) {
this.poseForm[m.id] = { name: '', photo_type: 'front', description: '' }
}
})
},
async addModel() {
const res = await fetch(API + '/api/models/add', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
name: this.newModelName || '型号_' + (this.models.length + 1),
description: this.newModelDesc || '',
notes: this.newModelNotes || '',
serial_prefix: this.newModelSerial || ''
})
})
const data = await res.json()
if (data.ok) {
await this.loadAllModels()
this.newModelName = ''
this.newModelDesc = ''
this.newModelNotes = ''
this.newModelSerial = ''
}
},
async deleteModel(modelId) {
if (!confirm('确定删除该机型?其下所有姿态将被删除!')) return
await fetch(API + '/api/models/delete/' + modelId, { method: 'DELETE' })
await this.loadAllModels()
},
// === 姿态管理(属于机型)===
async addPose(modelId, photoType, customName) {
const form = this.poseForm[modelId]
const type = photoType || form?.photo_type || 'front'
const name = customName || form?.name || type + '_' + ((this.getModel(modelId)?.poses?.filter(p => p.photo_type === type).length || 0) + 1)
await fetch(API + '/api/models/poses/add', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
model_id: modelId,
name: name,
photo_type: type,
arm_angles: this.currentAngles && this.currentAngles.length === 6 ? this.currentAngles : [0, 0, 0, 0, 0, 0],
speed: 500,
description: form?.description || ''
})
})
await this.loadAllModels()
if (form) {
form.name = ''
form.description = ''
}
},
async deletePose(modelId, poseId) {
if (!confirm('确定删除该姿态?')) return
await fetch(API + '/api/models/' + modelId + '/poses/' + poseId, { method: 'DELETE' })
await this.loadAllModels()
},
async updatePoseAngle(modelId, poseId, jointIndex, event) {
const value = parseFloat(event.target.value)
if (isNaN(value)) return
// Find the pose and update its angle
const model = this.getModel(modelId)
if (!model) return
const pose = model.poses.find(p => p.id === poseId)
if (!pose) return
if (!pose.arm_angles) pose.arm_angles = [0, 0, 0, 0, 0, 0]
pose.arm_angles[jointIndex] = value
// Save to backend
await fetch(API + '/api/models/' + modelId + '/poses/' + poseId, {
method: 'PUT',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ arm_angles: pose.arm_angles })
})
},
getModel(id) {
return this.models.find(m => m.id === id)
},
// === 任务配置 ===
async loadMissionConfig() {
try {
const res = await fetch(API + '/api/mission/config')
const data = await res.json()
if (data.ok && data.config) {
this.missionConfig.rows = data.config.rows || 3
this.missionConfig.cols = data.config.cols || 3
this.missionConfig.grid = data.config.grid || []
this.missionConfig.machines = data.machines || []
this.missionConfig.positions = data.config.positions || []
this.mapVersion++
}
} catch (e) { console.error('加载任务配置失败', e) }
},
async generateGrid() {
try {
const res = await fetch(API + '/api/mission/config', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
rows: this.missionConfig.rows,
cols: this.missionConfig.cols,
grid: []
})
})
const data = await res.json()
if (data.ok) {
this.missionConfig.grid = data.config.grid || []
alert('✅ 网格已生成 (' + this.missionConfig.rows + '×' + this.missionConfig.cols + ')')
} else {
alert('❌ 网格生成失败')
}
} catch (e) { alert('请求失败: ' + e.message) }
},
async saveMissionConfig() {
try {
const res = await fetch(API + '/api/mission/config', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
rows: this.missionConfig.rows,
cols: this.missionConfig.cols,
grid: this.missionConfig.grid
})
})
const data = await res.json()
if (data.ok) {
alert('✅ 网格配置已保存')
}
} catch (e) { alert('保存失败: ' + e.message) }
},
async loadAllMachines() {
try {
const res = await fetch(API + '/api/mission/machines')
const data = await res.json()
this.missionConfig.machines = data.machines || []
} catch (e) { console.error('加载机器列表失败', e) }
},
// ========== 点位行模型(独立于机器) ==========
/**
* 获取指定点位行的数据
* @param {number} pointRow - 点位行号,范围 0 ~ missionConfig.rows
* - pointRow=0 → 第1台机器的正面拍摄点
* - pointRow=rows → 最后1台机器的背面拍摄点
* - 中间 pointRow=i → 上面机器(i)的背面 + 下面机器(i+1)的正面
* @param {number} col - 列号(0-based
* @returns {object|null} 点位对象 { coords: [x,y,z], ... } 或 null
*/
getPointAt(pointRow, col) {
var positions = this.missionConfig.positions || []
// 在独立 positions 数组中查找 (pointRow, col)
for (var i = 0; i < positions.length; i++) {
var p = positions[i]
if (parseInt(p.row) === pointRow && parseInt(p.col) === col) {
// 优先找 shoot,其次找 front/back
if (p.side === 'shoot') return p
// 再看有没有同一(row,col)的shoot
}
}
// 再找同(row,col)的shoot类型
for (var i = 0; i < positions.length; i++) {
var p = positions[i]
if (parseInt(p.row) === pointRow && parseInt(p.col) === col && p.side === 'shoot') {
return p
}
}
// 兼容旧数据:尝试从机器对象的 front/back 获取
var machineAbove = this.getMachineAt(pointRow - 1, col) // 上面的机器
var machineBelow = this.getMachineAt(pointRow, col) // 下面的机器
if (pointRow === 0 && machineBelow) {
// 第一个点位行 → 下面机器的正面
return machineBelow.front || { coords: [0, 0, 0], poses: [] }
}
if (pointRow === this.missionConfig.rows && machineAbove) {
// 最后一个点位行 → 上面机器的背面
return machineAbove.back || { coords: [0, 0, 0], poses: [] }
}
// 中间点位行:优先返回上面机器的背面
if (machineAbove && machineAbove.back) {
return machineAbove.back
}
if (machineBelow && machineBelow.front) {
return machineBelow.front
}
return null
},
/**
* 获取点位的归属描述(用于弹窗标题)
* @param {number} pointRow - 点位行号
* @param {number} col - 列号
* @returns {string} 如 "第2列 · 机器2背面/机器3正面"
*/
getPointOwnerLabel(pointRow, col) {
var rows = this.missionConfig.rows
var labels = []
if (pointRow === 0) {
// 第一行点位:下面机器的正面
var m = this.getMachineAt(0, col)
if (m) labels.push('机器' + (m.row + 1) + '正面')
} else if (pointRow === rows) {
// 最后行点位:上面机器的背面
var m2 = this.getMachineAt(rows - 1, col)
if (m2) labels.push('机器' + (m2.row + 1) + '背面')
} else {
// 中间点位行:上面机器的背面 + 下面机器的正面
var mAbove = this.getMachineAt(pointRow - 1, col)
var mBelow = this.getMachineAt(pointRow, col)
if (mAbove) labels.push('机器' + (mAbove.row + 1) + '背面')
if (mBelow) labels.push('机器' + (mBelow.row + 1) + '正面')
}
if (labels.length === 0) return '第' + (col + 1) + '列 · 无归属'
return '第' + (col + 1) + '列 · ' + labels.join('/')
},
/**
* 检查点位是否可以清空
* 只有当上下两台机器都不需要这个点位时才能清空
*/
canClearPoint(pointRow, col) {
var rows = this.missionConfig.rows
// 如果上面有机器且机器存在 → 不能清空(上面机器需要此点位拍背面)
if (pointRow > 0 && pointRow <= rows) {
var mAbove = this.getMachineAt(pointRow - 1, col)
if (mAbove) return false
}
// 如果下面有机器且机器存在 → 不能清空(下面机器需要此点位拍正面)
if (pointRow >= 0 && pointRow < rows) {
var mBelow = this.getMachineAt(pointRow, col)
if (mBelow) return false
}
return true
},
getMachineAt(ri, ci) {
if (!this.missionConfig.machines) return null
return this.missionConfig.machines.find(function(m) { return m.row === ri && m.col === ci }) || null
},
// 打开点位编辑弹窗(基于点位行号)
openPointEdit(pointRow, col) {
var existing = this.getPointAt(pointRow, col)
if (existing && existing.coords) {
this.pointEditor.x = existing.coords[0] !== undefined ? existing.coords[0] : 0
this.pointEditor.y = existing.coords[1] !== undefined ? existing.coords[1] : 0
this.pointEditor.yaw = existing.coords[2] !== undefined ? existing.coords[2] : 0
} else {
this.pointEditor.x = 0
this.pointEditor.y = 0
this.pointEditor.yaw = 0
}
this.editingPoint = { pointRow: pointRow, col: col }
},
// 关闭点位编辑弹窗
closePointEdit() {
this.editingPoint = null
},
// 从AGV读取当前坐标到点位编辑器
async loadPointFromAgv() {
if (!this.agvConnected) { alert('请先连接AGV'); return }
try {
const res = await fetch(API + '/api/agv/position')
const pos = await res.json()
if (pos.ok && pos.position && Array.isArray(pos.position)) {
this.pointEditor.x = pos.position[0] ?? 0
this.pointEditor.y = pos.position[1] ?? 0
this.pointEditor.yaw = pos.position[2] ?? 0
alert('✅ 已读取AGV位置: (' + this.pointEditor.x.toFixed(2) + ', ' + this.pointEditor.y.toFixed(2) + ', ' + this.pointEditor.yaw.toFixed(2) + ')')
} else if (pos.ok && (!pos.position || !Array.isArray(pos.position))) {
alert('⚠️ AGV 未发布位置数据,请检查 AGV 传感器是否正常')
} else {
alert('读取AGV位置失败: ' + (pos.error || '未知错误'))
}
} catch (e) { alert('读取AGV位置失败: ' + e.message) }
},
// 保存点位配置到独立 positions 数组
async savePoint() {
if (!this.editingPoint) return
var pointRow = this.editingPoint.pointRow
var col = this.editingPoint.col
var coords = [this.pointEditor.x, this.pointEditor.y, this.pointEditor.yaw]
try {
// 保存到后端独立点位表(使用 point_row 标识点位行)
var saveRes = await fetch(API + '/api/mission/positions', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
row: pointRow,
col: col,
side: 'shoot', // 统一用 'shoot' 表示拍摄点位
coords: coords,
poses: []
})
})
var saveData = await saveRes.json()
if (!saveData.ok) {
alert('保存点位失败: ' + (saveData.error || '未知错误'))
return
}
// 同步更新关联机器的坐标(如果有的话)
this.syncPointToMachines(pointRow, col, coords)
await this.loadMissionConfig()
alert('✅ 点位坐标已保存: (' + coords[0].toFixed(2) + ', ' + coords[1].toFixed(2) + ', ' + coords[2].toFixed(2) + ')')
this.closePointEdit()
} catch (e) { alert('保存点位失败: ' + e.message) }
},
// 将点位坐标同步到关联的机器对象
syncPointToMachines(pointRow, col, coords) {
var rows = this.missionConfig.rows
// pointRow=0 → 同步到下面机器(0,col)的front
if (pointRow === 0) {
var m0 = this.getMachineAt(0, col)
if (m0) {
this.updateMachineSide(m0, 'front', coords)
}
return
}
// pointRow=rows → 同步到上面机器(rows-1,col)的back
if (pointRow === rows) {
var mLast = this.getMachineAt(rows - 1, col)
if (mLast) {
this.updateMachineSide(mLast, 'back', coords)
}
return
}
// 中间点位行 → 同步到上面机器的back + 下面机器的front
var mAbove = this.getMachineAt(pointRow - 1, col)
var mBelow = this.getMachineAt(pointRow, col)
if (mAbove) {
this.updateMachineSide(mAbove, 'back', coords)
}
if (mBelow) {
this.updateMachineSide(mBelow, 'front', coords)
}
},
// 更新机器某侧的坐标
async updateMachineSide(machine, side, coords) {
try {
var update = {}
update[side] = {
coords: coords,
poses: (machine[side] && machine[side].poses) ? machine[side].poses : []
}
await fetch(API + '/api/mission/machines/' + machine.id, {
method: 'PUT',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(update)
})
} catch (e) { console.error('同步机器坐标失败', e) }
},
// 清空点位(带保护检查)
async clearPoint() {
if (!this.editingPoint) return
var pointRow = this.editingPoint.pointRow
var col = this.editingPoint.col
// 检查是否可以清空
if (!this.canClearPoint(pointRow, col)) {
var ownerLabel = this.getPointOwnerLabel(pointRow, col)
alert('⚠️ 无法清空!此点位服务于: ' + ownerLabel + '\n必须先移除相关机器才能清空此点位。')
return
}
if (!confirm('确定清空此点位坐标?')) return
try {
// 发送清空请求(坐标归零或删除记录)
await fetch(API + '/api/mission/positions', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
row: pointRow,
col: col,
side: 'shoot',
coords: [0, 0, 0],
poses: []
})
})
this.pointEditor.x = 0
this.pointEditor.y = 0
this.pointEditor.yaw = 0
await this.loadMissionConfig()
} catch (e) { alert('清空点位失败: ' + e.message) }
},
onCellClick(ri, ci) {
var m = this.getMachineAt(ri, ci)
if (!m) {
// 无机器 → 创建机器记录
this.createMachine(ri, ci).then(function(ok) {
if (ok) {
var created = this.getMachineAt(ri, ci)
if (created) this.selectMachine(created)
}
}.bind(this))
} else {
// 有机器 → 切换为无机器(删除)
this.deleteMachine(m.id)
}
},
async createMachine(ri, ci) {
try {
var machineId = 'm_' + ri + '_' + ci
var res = await fetch(API + '/api/mission/machines/add', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
id: machineId,
row: ri,
col: ci,
front: { coords: [0, 0, 0], poses: [] },
back: { coords: [0, 0, 0], poses: [] }
})
})
var data = await res.json()
if (!data.ok && data.error !== '该位置已有机器') {
alert('创建机器失败: ' + (data.error || '未知错误'))
return false
}
await this.loadAllMachines()
return true
} catch (e) { alert('创建机器失败: ' + e.message); return false }
},
selectMachine(machine) {
if (!machine.front) machine.front = { coords: [0, 0, 0], poses: [] }
else if (!Array.isArray(machine.front.coords)) machine.front.coords = [0, 0, 0]
if (!machine.back) machine.back = { coords: [0, 0, 0], poses: [] }
else if (!Array.isArray(machine.back.coords)) machine.back.coords = [0, 0, 0]
this.selectedMachine = machine
},
clearSelection() {
this.selectedMachine = null
},
async deleteMachine(machineId) {
if (!confirm('确定删除此机器?\n\n注意:删除后其上方/下方的点位仍可继续使用。')) return
try {
await fetch(API + '/api/mission/machines/' + machineId, { method: 'DELETE' })
this.selectedMachine = null
await this.loadAllMachines()
await this.loadMissionConfig() // 重新加载点位,确保数据同步
} catch (e) { alert('删除失败: ' + e.message) }
},
async saveMachineCoords() {
if (!this.selectedMachine) return
try {
var res = await fetch(API + '/api/mission/machines/' + this.selectedMachine.id, {
method: 'PUT',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
front: this.selectedMachine.front,
back: this.selectedMachine.back
})
})
if (res.ok) {
this.mapMsg = '✅ 机器坐标已保存'
setTimeout(function() { this.mapMsg = '' }.bind(this), 2000)
} else {
alert('保存失败: ' + res.status)
}
} catch (e) { alert('保存失败: ' + e.message) }
},
async readPosition(side) {
if (!this.agvConnected) { alert('AGV 未连接'); return }
try {
var res = await fetch(API + '/api/agv/position')
var data = await res.json()
if (data.ok && data.position && Array.isArray(data.position)) {
var x = data.position[0]
var y = data.position[1]
var theta = data.position[2]
if (side === 'front') {
this.selectedMachine.front.coords = [x, y, theta]
} else {
this.selectedMachine.back.coords = [x, y, theta]
}
} else if (data.ok && (!data.position || !Array.isArray(data.position))) {
alert('⚠️ AGV 未发布位置数据,请检查 AGV 传感器是否正常')
} else {
alert('读取位置失败: ' + (data.error || '未知错误'))
}
} catch (e) { alert('读取位置失败: ' + e.message) }
},
async addPoseToMachine(machineId, side) {
var name = this.poseForm.name || '姿态' + (((this.selectedMachine && this.selectedMachine[side] && this.selectedMachine[side].poses) || []).length + 1)
try {
var res = await fetch(API + '/api/mission/poses/' + machineId + '/' + side, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
name: name,
arm_angles: this.currentAngles.length === 6 ? this.currentAngles : [0, 0, 0, 0, 0, 0],
speed: 500,
description: ''
})
})
var data = await res.json()
if (data.ok) {
this.poseForm.name = ''
await this.loadAllMachines()
var updated = this.getMachineAt(this.selectedMachine.row, this.selectedMachine.col)
if (updated) this.selectMachine(updated)
} else {
alert('添加姿态失败: ' + (data.error || '未知错误'))
}
} catch (e) { alert('添加姿态失败: ' + e.message) }
},
async deletePose(machineId, side, poseId) {
if (!confirm('确定删除此姿态?')) return
try {
await fetch(API + '/api/mission/poses/' + machineId + '/' + side + '/' + poseId, { method: 'DELETE' })
await this.loadAllMachines()
if (this.selectedMachine) {
var updated = this.getMachineAt(this.selectedMachine.row, this.selectedMachine.col)
if (updated) this.selectMachine(updated)
}
} catch (e) { alert('删除姿态失败: ' + e.message) }
},
async capturePosition(ri, ci, side) {
if (!this.agvConnected) { alert('请先连接AGV'); return }
var machine = this.getMachineAt(ri, ci)
if (!machine) {
try {
var machineId = 'm_' + ri + '_' + ci
var res = await fetch(API + '/api/mission/machines/add', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
id: machineId,
row: ri,
col: ci,
front: { coords: [0, 0, 0], poses: [] },
back: { coords: [0, 0, 0], poses: [] }
})
})
if (!res.ok) throw new Error('创建失败')
await this.loadAllMachines()
machine = this.getMachineAt(ri, ci)
} catch (e) { alert('创建机器失败: ' + e.message); return }
}
try {
var res = await fetch(API + '/api/agv/position')
var pos = await res.json()
var x = 0, y = 0, theta = 0
if (pos.ok && pos.position && Array.isArray(pos.position)) {
x = pos.position[0] || 0
y = pos.position[1] || 0
theta = pos.position[2] || 0
} else if (pos.ok && (!pos.position || !Array.isArray(pos.position))) {
alert('⚠️ AGV 未发布位置数据,请检查 AGV 传感器是否正常')
return
} else {
alert('读取位置失败: ' + (pos.error || '未知错误'))
return
}
if (!machine) { machine = this.getMachineAt(ri, ci) }
if (!machine) { alert('机器记录不存在'); return }
if (side === 'front') { machine.front.coords = [x, y, theta] } else { machine.back.coords = [x, y, theta] }
await fetch(API + '/api/mission/machines/' + machine.id, {
method: 'PUT',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(machine)
})
alert((side === 'front' ? '正面' : '背面') + '点位已更新: (' + x.toFixed(2) + ',' + y.toFixed(2) + ',' + theta.toFixed(2) + ')')
} catch (e) { alert('读取位置失败: ' + e.message) }
},
async refreshSequence() {
try {
var res = await fetch(API + '/api/mission/generate_sequence')
var data = await res.json()
if (data.ok) {
this.sequence = data.sequence || []
}
} catch (e) { console.error('刷新序列失败', e) }
},
// === 机械臂 ===
async refreshAngles() {
if (!this.armConnected) return
try {
var res = await fetch(API + '/api/arm/get_angles')
var data = await res.json()
if (data.ok && data.angles) {
this.currentAngles = data.angles
this.angleInputs = [...data.angles]
}
} catch (e) {}
},
async setAngle(idx, val) {
await fetch(API + '/api/arm/set_angle', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ joint: 'J' + (idx + 1), angle: val })
})
},
async applyAngles() {
await fetch(API + '/api/arm/set_angles', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ angles: this.angleInputs, speed: 500 })
})
},
jogStart(idx, dir) {
var joint = 'J' + (idx + 1)
fetch(API + '/api/arm/jog', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ joint, direction: dir })
})
this.jogIntervals[idx] = setInterval(function() { this.refreshAngles() }.bind(this), 200)
},
jogStop(idx) {
clearInterval(this.jogIntervals[idx])
var joint = 'J' + (idx + 1)
fetch(API + '/api/arm/jog', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ joint, direction: 0 })
})
setTimeout(function() { this.refreshAngles() }.bind(this), 300)
},
onPreviewError(e) {
e.target.style.display = 'none'
},
// === AGV 控制 ===
async refreshAgvPosition() {
if (!this.agvConnected) return
try {
var res = await fetch(API + '/api/agv/position')
var data = await res.json()
if (data.ok) {
this.agvPosition = data.position
this.agvBattery = data.battery
}
} catch (e) {}
},
agvMoveStart(dir) {
if (!this.agvConnected) return
fetch(API + '/api/agv/move', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ direction: dir, speed: this.agvSpeed })
})
},
agvMoveStop() {
fetch(API + '/api/agv/move', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ direction: 'stop' })
})
},
async agvStop() {
await fetch(API + '/api/agv/stop', { method: 'POST' })
},
async agvResetCollision() {
if (!this.agvConnected) {
alert('AGV 未连接')
return
}
if (!confirm('确定执行撞物体后复位?')) return
try {
var res = await fetch(API + '/api/agv/reset', { method: 'POST' })
var data = await res.json()
if (data.ok) {
alert('✅ ' + data.message)
await this.refresh()
await this.refreshAgvPosition()
} else {
alert('❌ 复位失败: ' + (data.error || '未知错误'))
}
} catch (e) {
alert('❌ 复位请求失败: ' + e.message)
}
},
}
})
const vm = app.mount('#app')
window.vm = vm // 暴露组件实例
File diff suppressed because one or more lines are too long
+722
View File
@@ -0,0 +1,722 @@
/* ========== 全局样式 ========== */
* { box-sizing: border-box; margin: 0; padding: 0; }
body {
font-family: -apple-system, "PingFang SC", "Microsoft YaHei", sans-serif;
background: #0f1923;
color: #e8eaed;
font-size: 14px;
min-height: 100vh;
}
a { color: #4fc3f7; text-decoration: none; }
a:hover { text-decoration: underline; }
.container {
max-width: 1200px;
margin: 0 auto;
padding: 20px 16px;
display: grid;
grid-template-columns: 1fr 1fr;
gap: 16px;
}
/* ========== 顶部栏 ========== */
.topbar {
background: #1a2332;
border-bottom: 1px solid #2a3441;
padding: 0 20px;
display: flex;
align-items: center;
height: 56px;
gap: 32px;
position: sticky;
top: 0;
z-index: 100;
}
.logo { font-size: 18px; font-weight: bold; color: #4fc3f7; }
.nav { display: flex; gap: 4px; }
.nav-link {
padding: 8px 16px;
border-radius: 6px;
color: #9aa0a6;
transition: all 0.2s;
}
.nav-link:hover { background: #2a3441; color: #e8eaed; text-decoration: none; }
.nav-link.active { background: #263238; color: #4fc3f7; }
.status-bar { margin-left: auto; display: flex; gap: 12px; }
.status-item {
padding: 4px 12px;
border-radius: 12px;
font-size: 12px;
font-weight: bold;
}
.status-item.setting { background: #1b3a2f; color: #4caf50; }
.status-item.running { background: #2a2a1b; color: #ffeb3b; }
.status-item.paused { background: #3a2a1a; color: #ff9800; }
.status-item.idle { background: #2a2a2a; color: #9aa0a6; }
/* ========== 卡片 ========== */
.card {
background: #1a2332;
border-radius: 12px;
padding: 20px;
border: 1px solid #2a3441;
}
.card h2 { font-size: 16px; margin-bottom: 16px; color: #4fc3f7; }
/* ========== 状态卡片 ========== */
.grid-3 { display: grid; grid-template-columns: repeat(3, 1fr); gap: 12px; margin-bottom: 16px; }
.status-card {
background: #0f1923;
border-radius: 8px;
padding: 16px;
text-align: center;
border: 1px solid #2a3441;
}
.status-card.ok { border-color: #2e7d32; background: #0d1f14; }
.status-card.error { border-color: #c62828; background: #1f0d0d; }
.status-icon { font-size: 24px; margin-bottom: 8px; }
.status-label { font-size: 12px; color: #9aa0a6; margin-bottom: 4px; }
.status-value { font-size: 14px; font-weight: bold; }
/* ========== 按钮 ========== */
.btn {
padding: 8px 16px;
border-radius: 6px;
border: none;
cursor: pointer;
font-size: 14px;
transition: all 0.2s;
background: #263238;
color: #e8eaed;
font-family: inherit;
}
.btn:hover:not(:disabled) { background: #37474f; }
.btn:disabled { opacity: 0.5; cursor: not-allowed; }
.btn-primary { background: #0277bd; color: #fff; }
.btn-primary:hover:not(:disabled) { background: #0288d1; }
.btn-secondary { background: #37474f; }
.btn-danger { background: #d32f2f; color: #fff; }
.btn-danger:hover:not(:disabled) { background: #f44336; }
.btn-success { background: #2e7d32; color: #fff; }
.btn-success:hover:not(:disabled) { background: #388e3c; }
.btn-warning { background: #e65100; color: #fff; }
.btn-error { background: #c62828; color: #fff; }
.btn-large { padding: 12px 24px; font-size: 16px; }
.btn-small { padding: 4px 10px; font-size: 12px; }
.btn-icon { background: none; border: none; cursor: pointer; font-size: 14px; padding: 4px; }
.btn-row { display: flex; gap: 8px; margin-top: 12px; flex-wrap: wrap; }
/* ========== 表单 ========== */
.form-group { margin-bottom: 12px; }
.form-group label { display: block; font-size: 12px; color: #9aa0a6; margin-bottom: 4px; }
.form-group input,
.form-group select {
width: 100%;
padding: 8px 12px;
background: #0f1923;
border: 1px solid #2a3441;
border-radius: 6px;
color: #e8eaed;
font-size: 14px;
font-family: inherit;
}
.form-group input:focus,
.form-group select:focus { outline: none; border-color: #4fc3f7; }
.form-row { display: grid; grid-template-columns: 1fr 1fr; gap: 12px; }
/* ========== Tabs ========== */
.tabs {
background: #1a2332;
border-bottom: 1px solid #2a3441;
padding: 0 20px;
display: flex;
gap: 4px;
}
.tab {
padding: 12px 20px;
background: none;
border: none;
color: #9aa0a6;
cursor: pointer;
font-size: 14px;
border-bottom: 2px solid transparent;
font-family: inherit;
}
.tab.active { color: #4fc3f7; border-bottom-color: #4fc3f7; }
.tab:hover { color: #e8eaed; }
/* ========== 摄像头预览 ========== */
.camera-preview {
width: 100%;
max-width: 480px;
border-radius: 8px;
overflow: hidden;
margin: 0 auto 16px;
background: #000;
}
.camera-preview img,
.camera-full img {
width: 100%;
display: block;
aspect-ratio: 16/9;
object-fit: cover;
}
.camera-full {
width: 100%;
border-radius: 8px;
overflow: hidden;
background: #000;
}
/* ========== 关节控制 ========== */
.joints-panel { margin-top: 16px; }
.joints-panel h3 { margin-bottom: 12px; font-size: 14px; }
.joint-grid { display: grid; grid-template-columns: repeat(3, 1fr); gap: 12px; }
.joint-control {
background: #0f1923;
border-radius: 8px;
padding: 12px;
text-align: center;
border: 1px solid #2a3441;
}
.joint-control label { font-size: 12px; color: #4fc3f7; font-weight: bold; }
.joint-value { font-size: 18px; font-weight: bold; color: #fff; margin: 4px 0; }
.joint-buttons { display: flex; align-items: center; gap: 4px; justify-content: center; }
.joint-buttons button {
width: 32px;
height: 32px;
border-radius: 4px;
border: 1px solid #2a3441;
background: #263238;
color: #e8eaed;
cursor: pointer;
font-size: 14px;
}
.joint-buttons input {
width: 60px;
padding: 4px;
text-align: center;
background: #0f1923;
border: 1px solid #2a3441;
border-radius: 4px;
color: #e8eaed;
font-size: 12px;
}
/* ========== 点位列表 ========== */
.point-item {
background: #0f1923;
border: 1px solid #2a3441;
border-radius: 8px;
padding: 12px;
margin-bottom: 12px;
}
.point-header { display: flex; align-items: center; gap: 8px; margin-bottom: 8px; }
.point-name { font-weight: bold; font-size: 15px; }
.point-coords { font-size: 12px; color: #9aa0a6; margin-bottom: 8px; }
.badge {
padding: 2px 8px;
border-radius: 10px;
font-size: 11px;
background: #263238;
color: #4fc3f7;
}
.pose-list { margin-top: 8px; }
.pose-item {
display: flex;
align-items: center;
gap: 8px;
padding: 6px 0;
border-bottom: 1px solid #2a3441;
font-size: 13px;
}
.angles { color: #9aa0a6; font-size: 11px; font-family: monospace; }
.pose-add {
display: flex;
gap: 8px;
align-items: center;
margin-top: 8px;
}
.pose-add input { flex: 1; padding: 6px 10px; background: #1a2332; border: 1px solid #2a3441; border-radius: 4px; color: #e8eaed; font-size: 13px; }
.pose-add select { padding: 6px; background: #1a2332; border: 1px solid #2a3441; border-radius: 4px; color: #e8eaed; }
.empty-hint { color: #9aa0a6; text-align: center; padding: 20px; }
.hint { font-size: 12px; color: #9aa0a6; margin-top: 8px; }
.alert { padding: 12px 16px; border-radius: 8px; margin-bottom: 12px; }
.alert-error { background: #1f0d0d; border: 1px solid #c62828; color: #ef5350; }
.checkbox-group { display: flex; gap: 16px; }
.checkbox-group label { display: flex; align-items: center; gap: 6px; cursor: pointer; color: #e8eaed; }
/* ========== 运行页面 ========== */
.running-header { display: flex; align-items: center; gap: 20px; margin-bottom: 16px; }
.running-status {
font-size: 18px;
font-weight: bold;
display: flex;
align-items: center;
gap: 8px;
}
.running-status.idle { color: #9aa0a6; }
.running-status.running { color: #4caf50; }
.running-status.paused { color: #ff9800; }
.pulse {
width: 10px;
height: 10px;
border-radius: 50%;
background: currentColor;
animation: pulse 1.5s infinite;
}
@keyframes pulse {
0%, 100% { opacity: 1; transform: scale(1); }
50% { opacity: 0.5; transform: scale(0.8); }
}
.running-progress { flex: 1; display: flex; align-items: center; gap: 12px; }
.progress-bar { flex: 1; height: 8px; background: #2a3441; border-radius: 4px; overflow: hidden; }
.progress-fill { height: 100%; background: #4fc3f7; border-radius: 4px; transition: width 0.3s; }
/* ========== 报告 ========== */
.report-summary { display: flex; gap: 16px; margin-bottom: 16px; }
.stat { padding: 8px 16px; border-radius: 8px; background: #0f1923; border: 1px solid #2a3441; }
.stat.ok { border-color: #2e7d32; color: #4caf50; }
.stat.error { border-color: #c62828; color: #ef5350; }
.report-item { padding: 8px 12px; background: #0f1923; border-radius: 6px; margin-bottom: 8px; border: 1px solid #2a3441; }
.report-point { display: flex; align-items: center; gap: 8px; font-weight: bold; }
.report-status { font-size: 16px; }
.report-pose { font-size: 12px; color: #9aa0a6; padding-left: 24px; margin-top: 4px; }
/* ========== 响应式 ========== */
@media (max-width: 768px) {
.container { grid-template-columns: 1fr; }
.grid-3 { grid-template-columns: 1fr; }
.form-row { grid-template-columns: 1fr; }
.joint-grid { grid-template-columns: repeat(2, 1fr); }
.form-row { grid-template-columns: 1fr; }
}
/* AGV 移动控制面板 */
.agv-status-bar {
display: flex;
gap: 16px;
align-items: center;
padding: 10px 14px;
background: #0f1923;
border-radius: 8px;
margin-bottom: 16px;
font-size: 13px;
color: #9aa0a6;
flex-wrap: wrap;
}
.agv-status-bar strong { color: #e8eaed; }
.agv-control-panel {
display: flex;
flex-direction: column;
align-items: center;
gap: 6px;
max-width: 280px;
margin: 0 auto;
}
.agv-dir-row {
display: grid;
grid-template-columns: 80px 80px 80px;
gap: 6px;
width: 100%;
}
.agv-dir-placeholder { width: 80px; height: 44px; }
.agv-btn {
height: 44px;
border-radius: 8px;
border: 1px solid #2a3441;
background: #263238;
color: #e8eaed;
cursor: pointer;
font-size: 13px;
font-family: inherit;
transition: background 0.15s;
display: flex;
align-items: center;
justify-content: center;
gap: 4px;
user-select: none;
}
.agv-btn:active, .agv-btn:focus { outline: none; }
.agv-btn-up { background: #1b3a2f; border-color: #2e7d32; color: #4caf50; }
.agv-btn-down { background: #3a1b1b; border-color: #7d2e2e; color: #f44336; }
.agv-btn-left { background: #1b2d3a; border-color: #1565c0; color: #42a5f5; }
.agv-btn-right { background: #2d2a1b; border-color: #7d6e2e; color: #ffc107; }
.agv-btn-stop { background: #37474f; border-color: #546e7a; }
.agv-btn-up:active { background: #1e4d38; }
.agv-btn-down:active { background: #4d2020; }
.agv-btn-left:active { background: #1e3a4d; }
.agv-btn-right:active { background: #3d3820; }
.agv-btn-stop:active { background: #455a64; }
.agv-btn-lateral {
background: #2d1b4a;
border-color: #7c4dff;
color: #b388ff;
font-size: 13px;
min-width: 120px;
}
.agv-btn-lateral:active { background: #3d2560; }
.agv-lateral-row {
display: flex;
gap: 12px;
justify-content: center;
margin-top: 8px;
max-width: 280px;
width: 100%;
}
.speed-control {
display: flex;
align-items: center;
gap: 10px;
}
.speed-value {
min-width: 44px;
text-align: right;
font-weight: bold;
color: #4fc3f7;
}
/* 双摄像头预览布局 */
.camera-row {
display: grid;
grid-template-columns: 1fr 1fr;
gap: 16px;
margin-top: 12px;
}
.camera-box {
background: #111;
border-radius: 8px;
overflow: hidden;
}
.camera-label {
padding: 8px 12px;
font-size: 13px;
color: #aaa;
background: #1a1a1a;
border-bottom: 1px solid #333;
}
.camera-img {
width: 100%;
display: block;
aspect-ratio: 4/3;
object-fit: cover;
}
.camera-placeholder {
width: 100%;
aspect-ratio: 4/3;
display: flex;
align-items: center;
justify-content: center;
color: #666;
font-size: 14px;
}
/* ========== 地图标记 ========== */
.map-marker {
position: absolute;
transform: translate(-50%, -100%);
font-size: 20px;
cursor: pointer;
filter: drop-shadow(0 2px 4px rgba(0,0,0,0.5));
z-index: 10;
}
.map-marker:hover {
transform: translate(-50%, -100%) scale(1.2);
}
/* ========== 任务配置 M×N 网格 ========== */
.mission-grid-wrap {
margin-top: 12px;
overflow-x: auto;
}
.mission-grid {
display: grid;
gap: 4px;
grid-template-columns: 80px repeat(var(--cols,4), 90px);
}
.grid-cell {
min-width: 80px;
min-height: 48px;
display: flex;
align-items: center;
justify-content: center;
border-radius: 6px;
font-size: 12px;
cursor: pointer;
border: 1px solid #2a3441;
background: #0f1923;
transition: background 0.15s, border-color 0.15s;
}
.grid-cell.active {
background: #1b3a2f;
border-color: #2e7d32;
color: #4caf50;
}
.grid-cell.active:hover {
background: #234;
}
.grid-cell.selected {
border-color: #4fc3f7 !important;
box-shadow: 0 0 0 2px #4fc3f7;
}
.grid-header {
background: transparent;
border-color: transparent;
cursor: default;
font-weight: bold;
color: #9aa0a6;
font-size: 12px;
}
/* 机器配置表单 */
.machine-form {
background: #0f1923;
border: 1px solid #2a3441;
border-radius: 8px;
padding: 16px;
margin-top: 12px;
}
.machine-form h3 {
font-size: 14px;
color: #4fc3f7;
margin-bottom: 10px;
}
.machine-form h4 {
font-size: 13px;
color: #9aa0a6;
margin: 8px 0 6px;
}
/* 姿态列表 */
.pose-list {
margin-top: 8px;
}
.pose-item {
display: flex;
align-items: center;
gap: 8px;
padding: 6px 0;
border-bottom: 1px solid #2a3441;
font-size: 13px;
}
.pose-name {
font-weight: bold;
min-width: 80px;
}
.pose-angles {
color: #9aa0a6;
font-size: 11px;
font-family: monospace;
flex: 1;
}
.pose-add {
display: flex;
gap: 8px;
align-items: center;
margin-top: 8px;
}
.pose-add input {
flex: 1;
padding: 6px 10px;
background: #1a2332;
border: 1px solid #2a3441;
border-radius: 4px;
color: #e8eaed;
font-size: 13px;
}
/* 蛇形序列预览 */
.sequence-preview {
display: flex;
flex-direction: column;
gap: 4px;
max-height: 320px;
overflow-y: auto;
}
.sequence-step {
display: flex;
align-items: center;
gap: 10px;
padding: 6px 12px;
background: #0f1923;
border-radius: 6px;
border: 1px solid #2a3441;
font-size: 13px;
}
.step-index {
background: #263238;
color: #4fc3f7;
border-radius: 10px;
min-width: 28px;
height: 22px;
display: flex;
align-items: center;
justify-content: center;
font-size: 11px;
font-weight: bold;
}
.step-info {
flex: 1;
}
.step-side {
padding: 2px 8px;
border-radius: 8px;
font-size: 11px;
font-weight: bold;
}
.step-side:contains('正面') {
background: #1b3a2f;
color: #4caf50;
}
.step-side:contains('背面') {
background: #3a1b2f;
color: #ce93d8;
}
/* 网格单元格点位配置 */
.cell-machine {
font-size: 11px;
font-weight: bold;
color: #2c3e50;
}
.cell-points {
margin-top: 2px;
font-size: 9px;
}
.point-row {
display: flex;
align-items: center;
gap: 2px;
padding: 1px 2px;
background: #f8f9fa;
border-radius: 3px;
cursor: pointer;
margin: 1px 0;
}
.point-row:hover {
background: #e9ecef;
}
.point-label {
color: #666;
min-width: 24px;
}
.point-coords {
color: #0366d6;
font-family: monospace;
font-size: 8px;
flex: 1;
}
.btn-icon-small {
background: none;
border: none;
cursor: pointer;
font-size: 10px;
padding: 1px 3px;
border-radius: 3px;
}
.btn-icon-small:hover {
background: #ddd;
}
/* ========== 任务配置 弹窗 + 网格增强样式 ========== */
/* 弹窗遮罩 */
.modal-overlay {
position: fixed;
inset: 0;
background: rgba(0,0,0,0.65);
display: flex;
align-items: center;
justify-content: center;
z-index: 1000;
}
.modal-box {
background: #1a1f2e;
border: 1px solid #2a3a50;
border-radius: 12px;
padding: 20px 24px;
min-width: 380px;
max-width: 500px;
box-shadow: 0 8px 32px rgba(0,0,0,0.5);
}
.modal-box h3 { margin: 0 0 8px; color: #e0e6f0; font-size: 16px; }
/* 点位行单元格 */
.point-cell { cursor: pointer; flex-direction: column; gap: 2px; }
.point-cell:hover { border-color: #4fc3f7; background: #162030; }
.point-cell.point-filled { background: #0d2535; border-color: #1565c0; }
.point-coords { font-size: 10px; color: #64b5f6; font-family: monospace; }
.point-empty { font-size: 10px; color: #455a64; }
/* 机器行单元格 */
.machine-cell { cursor: pointer; }
.machine-cell:hover { border-color: #4caf50; background: #1b3a2f; }
.machine-cell.active { background: #1b3a2f; border-color: #2e7d32; color: #4caf50; }
.machine-icon { font-size: 18px; }
.machine-empty { font-size: 16px; color: #455a64; }
/* ========== 任务配置 弹窗 + 网格增强样式 ========== */
/* 弹窗遮罩 */
.modal-overlay {
position: fixed;
inset: 0;
background: rgba(0,0,0,0.65);
display: flex;
align-items: center;
justify-content: center;
z-index: 1000;
}
.modal-box {
background: #1a1f2e;
border: 1px solid #2a3a50;
border-radius: 12px;
padding: 20px 24px;
min-width: 380px;
max-width: 500px;
box-shadow: 0 8px 32px rgba(0,0,0,0.5);
}
.modal-box h3 { margin: 0 0 8px; color: #e0e6f0; font-size: 16px; }
/* 点位行单元格 */
.point-cell { cursor: pointer; flex-direction: column; gap: 2px; }
.point-cell:hover { border-color: #4fc3f7; background: #162030; }
.point-cell.point-filled { background: #0d2535; border-color: #1565c0; }
.point-coords { font-size: 10px; color: #64b5f6; font-family: monospace; }
.point-empty { font-size: 10px; color: #455a64; }
/* 机器行单元格 */
.machine-cell { cursor: pointer; }
.machine-cell:hover { border-color: #4caf50; background: #1b3a2f; }
.machine-cell.active { background: #1b3a2f; border-color: #2e7d32; color: #4caf50; }
.machine-icon { font-size: 18px; }
.machine-empty { font-size: 16px; color: #455a64; }
/* 点位编辑弹窗 */
.modal-overlay .modal-box { min-width: 420px; }
.modal-overlay .form-row { gap: 8px; }
.modal-overlay .btn-row { gap: 8px; flex-wrap: wrap; }
/* 地图坐标点覆盖层 */
.map-container { position: relative; }
.map-overlay {
position: absolute; top: 0; left: 0; right: 0; bottom: 0;
pointer-events: none;
}
.map-dot {
position: absolute;
transform: translate(-50%, -50%);
}
.point-dot {
width: 10px; height: 10px;
background: #f39c12;
border-radius: 50%;
border: 2px solid #fff;
box-shadow: 0 0 6px rgba(243,156,18,0.9);
}
+148
View File
@@ -0,0 +1,148 @@
<!DOCTYPE html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>AGV 拍摄系统</title>
<link rel="stylesheet" href="/static/css/style.css">
</head>
<body>
<div id="app">
<!-- 顶部导航 -->
<header class="topbar">
<div class="logo">🤖 AGV 拍摄系统</div>
<nav class="nav">
<a href="/" class="nav-link active">🏠 首页</a>
<a href="/setting" class="nav-link">⚙️ 设置</a>
<a href="/running" class="nav-link">▶️ 运行</a>
</nav>
<div class="status-bar">
<span class="status-item" :class="statusClass">
[[ statusText ]]
</span>
</div>
</header>
<!-- 主内容 -->
<main class="container">
<!-- 系统状态卡片 -->
<section class="card">
<h2>📡 系统连接状态</h2>
<div class="grid-3">
<div class="status-card" :class="agvConnected ? 'ok' : 'error'"
@click="connectDevice('agv')" style="cursor:pointer">
<div class="status-icon">
<span v-if="reconnectingDevice==='agv'"></span>
<span v-else>[[ agvConnected ? '✅' : '❌' ]]</span>
</div>
<div class="status-label">AGV</div>
<div class="status-value">
<span v-if="reconnectingDevice==='agv'">重连中...</span>
<span v-else>[[ agvConnected ? '已连接' : '未连接' ]](点击重连)</span>
</div>
</div>
<div class="status-card" :class="armConnected ? 'ok' : 'error'"
@click="connectDevice('arm')" style="cursor:pointer">
<div class="status-icon">
<span v-if="reconnectingDevice==='arm'"></span>
<span v-else>[[ armConnected ? '✅' : '❌' ]]</span>
</div>
<div class="status-label">机械臂</div>
<div class="status-value">
<span v-if="reconnectingDevice==='arm'">重连中...</span>
<span v-else>[[ armConnected ? '已连接' : '未连接' ]](点击重连)</span>
</div>
</div>
<div class="status-card" :class="cameraOpened ? 'ok' : 'error'"
@click="connectDevice('camera')" style="cursor:pointer">
<div class="status-icon">
<span v-if="reconnectingDevice==='camera'"></span>
<span v-else>[[ cameraOpened ? '✅' : '❌' ]]</span>
</div>
<div class="status-label">AGV摄像头</div>
<div class="status-value">
<span v-if="reconnectingDevice==='camera'">重连中...</span>
<span v-else>[[ cameraOpened ? '已打开' : '未打开' ]](点击重连)</span>
</div>
</div>
<div class="status-card" :class="armCameraOpened ? 'ok' : 'error'"
@click="connectDevice('arm_camera')" style="cursor:pointer">
<div class="status-icon">
<span v-if="reconnectingDevice==='arm_camera'"></span>
<span v-else>[[ armCameraOpened ? '✅' : '❌' ]]</span>
</div>
<div class="status-label">机械臂摄像头</div>
<div class="status-value">
<span v-if="reconnectingDevice==='arm_camera'">重连中...</span>
<span v-else>[[ armCameraOpened ? '已打开' : '未打开' ]](点击重连)</span>
</div>
</div>
</div>
<div class="btn-row">
<button class="btn btn-primary" @click="connectAll" :disabled="connecting">
[[ connecting ? '连接中...' : '🔗 连接全部设备' ]]
</button>
<button class="btn btn-secondary" @click="disconnectAll" :disabled="!agvConnected && !armConnected && !cameraOpened">
断开连接
</button>
</div>
</section>
<!-- 双摄像头预览 -->
<section class="card">
<h2>📷 摄像头预览</h2>
<div class="camera-row">
<div class="camera-box">
<div class="camera-label">AGV 摄像头 <button class="btn btn-small" @click="agvCameraSrc='/api/camera/refresh?t='+Date.now(); agvCameraError=false">刷新</button></div>
<img v-if="cameraOpened && !agvCameraError" :src="agvCameraSrc" class="camera-img" @error="agvCameraError=true">
<div v-if="cameraOpened && agvCameraError" class="camera-placeholder">AGV 摄像头异常</div>
<div v-else-if="!cameraOpened" class="camera-placeholder">未打开(先点击连接设备)</div>
</div>
<div class="camera-box">
<div class="camera-label">机械臂摄像头 <button class="btn btn-small" @click="armCameraSrc='/api/camera/arm_refresh?t='+Date.now(); armCameraError=false">刷新</button></div>
<img v-if="armConnected && !armCameraError" :src="armCameraSrc" class="camera-img" @error="armCameraError=true">
<div v-if="armConnected && armCameraError" class="camera-placeholder">机械臂摄像头异常</div>
<div v-else-if="!armConnected" class="camera-placeholder">未连接</div>
</div>
</div>
</section>
<!-- 地图信息 -->
<section class="card">
<h2>🗺️ 地图信息</h2>
<div v-if="mapLoaded">
<p>地图目录: <code>[[ mapConfig.map_dir ]]</code></p>
<p>地图文件: <code>[[ mapConfig.map_file ]]</code></p>
</div>
<div v-else>
<p class="hint">尚未加载地图,请前往 <a href="/setting">设置页面</a> 配置地图</p>
</div>
</section>
<!-- 点位概览 -->
<section class="card">
<h2>📍 点位概览</h2>
<p>已配置 <strong>[[ pointsCount ]]</strong> 个拍摄点位</p>
<div class="btn-row">
<a href="/setting" class="btn btn-primary">前往设置点位 →</a>
</div>
</section>
<!-- 快捷入口 -->
<section class="card">
<h2>🚀 快捷入口</h2>
<div class="btn-row">
<a href="/setting" class="btn btn-large btn-primary">进入设置模式 ⚙️</a>
<a href="/running" class="btn btn-large btn-success" :class="{disabled: !allReady}" @click.prevent="checkReady">
开始运行 ▶️
</a>
</div>
<p v-if="!allReady" class="hint">请先连接所有设备并加载地图</p>
</section>
</main>
</div>
<script src="/static/js/vue3.global.prod.js"></script>
<script src="/static/js/app.js"></script>
</body>
</html>
+82
View File
@@ -0,0 +1,82 @@
<!DOCTYPE html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>运行监控 - AGV 拍摄系统</title>
<link rel="stylesheet" href="/static/css/style.css">
</head>
<body>
<div id="app">
<header class="topbar">
<div class="logo">▶️ 任务运行</div>
<nav class="nav">
<a href="/" class="nav-link">🏠 首页</a>
<a href="/setting" class="nav-link">⚙️ 设置</a>
<a href="/running" class="nav-link active">▶️ 运行</a>
</nav>
</header>
<main class="container">
<!-- 状态概览 -->
<section class="card">
<div class="running-header">
<div class="running-status" :class="missionState">
<span class="pulse"></span>
[[ missionStateText ]]
</div>
<div class="running-progress" v-if="missionState === 'running'">
<span>点位 [[ currentPoint + 1 ]] / [[ totalPoints ]]</span>
<div class="progress-bar">
<div class="progress-fill" :style="{width: progressPercent + '%'}"></div>
</div>
</div>
</div>
<div class="btn-row">
<button class="btn btn-success btn-large" @click="startMission" :disabled="missionState !== 'idle'">
▶️ 开始任务
</button>
<button class="btn btn-warning btn-large" @click="pauseMission" :disabled="missionState !== 'running'">
⏸️ 暂停
</button>
<button class="btn btn-error btn-large" @click="stopMission" :disabled="missionState === 'idle'">
⏹️ 停止
</button>
</div>
</section>
<!-- 实时预览 -->
<section class="card">
<h2>📷 摄像头预览</h2>
<div class="camera-full">
<img :src="previewUrl" @error="onPreviewError">
</div>
</section>
<!-- 任务报告 -->
<section class="card" v-if="report">
<h2>📋 任务报告</h2>
<div class="report-summary">
<div class="stat ok">✅ 完成: [[ report.completed ]]</div>
<div class="stat error">❌ 失败: [[ report.failed ]]</div>
<div class="stat">总计: [[ report.total_points ]]</div>
</div>
<div class="report-details">
<div v-for="(detail, i) in report.details" :key="i" class="report-item">
<div class="report-point">
<span class="report-status" :class="detail.status">[[ detail.status === 'completed' ? '✅' : '❌' ]]</span>
[[ detail.point_name ]]
</div>
<div v-for="(pose, pi) in detail.poses" :key="pi" class="report-pose">
[[ pose.photo_type ]] - [[ pose.status ]]
</div>
</div>
</div>
</section>
</main>
</div>
<script src="/static/js/vue3.global.prod.js"></script>
<script src="/static/js/running.js"></script>
</body>
</html>
+507
View File
@@ -0,0 +1,507 @@
<!DOCTYPE html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>设置 - AGV 拍摄系统</title>
<link rel="stylesheet" href="/static/css/style.css?v=20260514e">
</head>
<body>
<div id="app">
<header class="topbar">
<div class="logo">⚙️ 系统设置</div>
<nav class="nav">
<a href="/" class="nav-link">🏠 首页</a>
<href="/setting" class="nav-link active">⚙️ 设置</a>
<a href="/running" class="nav-link">▶️ 运行</a>
</nav>
</header>
<!-- Tabs -->
<div class="tabs">
<button class="tab" :class="{active: tab === 'map'}" @click="tab = 'map'">🗺️ 地图</button>
<button class="tab" :class="{active: tab === \'model\'}" @click="tab = \'model\'">📦 机型配置</button>
<button class="tab" :class="{active: tab === 'mission'}" @click="tab = 'mission'">🎯 任务配置</button>
<button class="tab" :class="{active: tab === 'arm'}" @click="tab = 'arm'">🤖 机械臂</button>
<button class="tab" :class="{active: tab === 'agv'}" @click="tab = 'agv'">🚗 AGV控制</button>
</div>
<main class="container">
<!-- 地图配置 (保持不变) -->
<div v-if="tab === 'map'">
<section class="card">
<h2>地图配置</h2>
<div class="form-row">
<div class="form-group">
<label>地图目录</label>
<input type="text" v-model="mapForm.map_dir" placeholder="/home/elephant/...">
</div>
<div class="form-group">
<label>地图文件</label>
<input type="text" v-model="mapForm.map_file" placeholder="map.yaml">
</div>
<div class="form-group" style="align-self:end">
<button class="btn btn-primary" @click="loadMap">📂 加载地图</button>
<button class="btn btn-secondary" @click="saveMap" style="margin-left:6px">💾 保存</button>
</div>
</div>
<p v-if="mapMsg" class="hint">{% raw %}{{ mapMsg }}{% endraw %}</p>
</section>
<section class="card" v-if="mapLoaded" style="margin-top:16px">
<h2>地图可视化</h2>
<div class="map-container" style="position:relative;background:#111;border-radius:8px;overflow:hidden">
<img :src="mapImageUrl" @error="onMapError" style="width:100%;display:block">
<!-- 地图覆盖层:显示点位坐标 -->
<div class="map-overlay">
<!-- 点位坐标点 -->
<div v-for="(p, pi) in missionConfig.positions" :key="'pdot-'+mapVersion+'-'+pi"
class="map-dot point-dot"
:style="{ left: getMapX(p.coords) + '%', top: getMapY(p.coords) + '%' }"
:title="p.coords ? p.coords.map(c => c.toFixed(2)).join(', ') : ''">
</div>
</div>
</div>
</section>
</div>
<!-- ========== 机型配置 Tab ========== -->
<div v-if="tab === 'model'">
<section class="card">
<h2>📦 机型配置</h2>
<!-- 添加新机型 -->
<div class="form-section" style="background:#f5f5f5;padding:16px;border-radius:8px;margin-bottom:20px">
<h3 style="margin-top:0">添加新机型</h3>
<div class="form-row">
<div class="form-group" style="flex:1">
<label>机型名称</label>
<input type="text" v-model="newModelName" placeholder="例如:SMT-A" style="width:100%;padding:8px;border:1px solid #ddd;border-radius:4px">
</div>
</div>
<div class="form-row">
<div class="form-group" style="flex:1">
<label>描述</label>
<input type="text" v-model="newModelDesc" placeholder="描述信息" style="width:100%;padding:8px;border:1px solid #ddd;border-radius:4px">
</div>
<div class="form-group" style="flex:1">
<label>备注</label>
<input type="text" v-model="newModelNotes" placeholder="备注信息" style="width:100%;padding:8px;border:1px solid #ddd;border-radius:4px">
</div>
</div>
<button class="btn btn-primary" @click="addModel" style="margin-top:8px"> 添加机型</button>
</div>
<!-- 机型列表 -->
<div v-if="models.length === 0" style="text-align:center;color:#888;padding:40px">
<p>暂无机型配置,请添加新机型</p>
</div>
<div v-else>
<div v-for="m in models" :key="m.id" style="border:1px solid #ddd;border-radius:8px;margin-bottom:20px;overflow:hidden">
<!-- 机型头部 -->
<div style="background:#e8f4e8;padding:12px 16px;display:flex;justify-content:space-between;align-items:center">
<div>
<strong style="font-size:16px">{{ m.name }}</strong>
<span style="margin-left:12px;color:#666;font-size:13px">ID: {{ m.id }}</span>
<span v-if="m.description" style="margin-left:12px;color:#888;font-size:13px">{{ m.description }}</span>
<span v-if="m.notes" style="margin-left:12px;color:#aaa;font-size:13px">【{{ m.notes }}】</span>
</div>
<div style="display:flex;gap:8px">
<button class="btn btn-danger btn-small" @click="deleteModel(m.id)">🗑️ 删除机型</button>
</div>
</div>
<!-- 姿态配置 -->
<div style="padding:16px">
<!-- 正面姿态 -->
<div style="margin-bottom:20px">
<h4 style="margin:0 0 12px 0;color:#1976d2">🔵 正面姿态</h4>
<div v-for="pose in m.poses.filter(p => p.photo_type === 'front')" :key="pose.id" style="background:#f8f8f8;padding:12px;border-radius:6px;margin-bottom:8px">
<div style="display:flex;justify-content:space-between;align-items:center;margin-bottom:8px">
<strong>{{ pose.name or '正面姿态' }}</strong>
<button class="btn btn-danger btn-small" @click="deletePose(m.id, pose.id)">删除</button>
</div>
<div style="display:flex;gap:8px;flex-wrap:wrap">
<div v-for="j in 6" :key="j" style="display:flex;align-items:center;gap:4px">
<span style="font-size:12px;color:#666">J{{j}}</span>
<input type="number" step="0.1"
:value="pose.arm_angles && pose.arm_angles[j-1] !== undefined ? pose.arm_angles[j-1] : 0"
@change="updatePoseAngle(m.id, pose.id, j-1, )"
style="width:70px;padding:4px;border:1px solid #ddd;border-radius:4px">
<span style="font-size:11px;color:#999">°</span>
</div>
</div>
</div>
<!-- 添加正面姿态 -->
<div style="margin-top:8px">
<div style="display:flex;gap:8px;align-items:center;flex-wrap:wrap">
<input type="text" v-model="newPoseForm[m.id + '_front']"
placeholder="姿态名称(如:取料)"
style="flex:1;min-width:120px;padding:6px;border:1px solid #ddd;border-radius:4px">
<button class="btn btn-secondary btn-small" @click="addPose(m.id, 'front', newPoseForm[m.id + '_front'])"> 添加正面姿态(当前角度)</button>
</div>
<div style="margin-top:6px;font-size:12px;color:#888">
当前机械臂角度:
<span v-if="currentAngles.length">
J1={{ currentAngles[0]?.toFixed(1) }}° J2={{ currentAngles[1]?.toFixed(1) }}° J3={{ currentAngles[2]?.toFixed(1) }}° J4={{ currentAngles[3]?.toFixed(1) }}° J5={{ currentAngles[4]?.toFixed(1) }}° J6={{ currentAngles[5]?.toFixed(1) }}°
</span>
<span v-else>(未连接机械臂)</span>
</div>
</div>
</div>
<!-- 背面姿态 -->
<div>
<h4 style="margin:0 0 12px 0;color:#d32f2f">🔴 背面姿态</h4>
<div v-for="pose in m.poses.filter(p => p.photo_type === 'back')" :key="pose.id" style="background:#fff0f0;padding:12px;border-radius:6px;margin-bottom:8px">
<div style="display:flex;justify-content:space-between;align-items:center;margin-bottom:8px">
<strong>{{ pose.name or '背面姿态' }}</strong>
<button class="btn btn-danger btn-small" @click="deletePose(m.id, pose.id)">删除</button>
</div>
<div style="display:flex;gap:8px;flex-wrap:wrap">
<div v-for="j in 6" :key="j" style="display:flex;align-items:center;gap:4px">
<span style="font-size:12px;color:#666">J{{j}}</span>
<input type="number" step="0.1"
:value="pose.arm_angles && pose.arm_angles[j-1] !== undefined ? pose.arm_angles[j-1] : 0"
@change="updatePoseAngle(m.id, pose.id, j-1, )"
style="width:70px;padding:4px;border:1px solid #ddd;border-radius:4px">
<span style="font-size:11px;color:#999">°</span>
</div>
</div>
</div>
<!-- 添加背面姿态 -->
<div style="margin-top:8px">
<div style="display:flex;gap:8px;align-items:center;flex-wrap:wrap">
<input type="text" v-model="newPoseForm[m.id + '_back']"
placeholder="姿态名称(如:放料)"
style="flex:1;min-width:120px;padding:6px;border:1px solid #ddd;border-radius:4px">
<button class="btn btn-secondary btn-small" @click="addPose(m.id, 'back', newPoseForm[m.id + '_back'])"> 添加背面姿态(当前角度)</button>
</div>
</div>
</div>
</div>
</div>
</div>
</section>
</div>
<!-- ========== 任务配置 Tab ========== -->
<div v-if="tab === 'mission'">
<!-- 上:网格配置 -->
<section class="card">
<h2>① 网格配置 (M×N)</h2>
<div class="form-row">
<div class="form-group">
<label>行数 M</label>
<input type="number" v-model.number="missionConfig.rows" min="1" max="20" placeholder="3">
</div>
<div class="form-group">
<label>列数 N</label>
<input type="number" v-model.number="missionConfig.cols" min="1" max="20" placeholder="4">
</div>
<div class="form-group" style="align-self:end">
<button class="btn btn-primary" @click="generateGrid">🔲 生成网格</button>
<button class="btn btn-secondary" @click="saveMissionConfig" style="margin-left:6px">💾 保存网格</button>
</div>
</div>
<!-- 网格可视化 - 点位行独立于机器,始终可配置 -->
<div v-if="missionConfig.rows > 0" class="mission-grid-wrap" style="margin-top:12px">
<div class="mission-grid" :style="{ gridTemplateColumns: '90px repeat(' + missionConfig.cols + ', 100px)' }">
<!-- 表头: 列号 -->
<div class="grid-cell grid-header"></div>
<div v-for="c in missionConfig.cols" :key="'h'+c" class="grid-cell grid-header">第{% raw %}{{ c }}{% endraw %}列</div>
<!-- 循环渲染: 点位行(0) → 机器行(1) → 点位行(1) → 机器行(2) → ... → 点位行(rows) -->
<!-- pointRow 从 0 到 rows(共 rows+1 个点位行)-->
<!-- machineRow 从 1 到 rows(共 rows 个机器行)-->
<!-- 第一个点位行 (pointRow=0): 所有机器的正面拍摄点 -->
<div class="grid-cell grid-header">点位行 1</div>
<div v-for="(ci) in missionConfig.cols" :key="'p0_'+ci"
class="grid-cell point-cell"
@click="openPointEdit(0, ci-1)">
<span class="point-coords">{% raw %}{{ getPointAt(0, ci-1)?.coords?.[0]?.toFixed(1) || '-' }},{{ getPointAt(0, ci-1)?.coords?.[1]?.toFixed(1) || '-' }}{% endraw %}</span>
<button class="btn-icon-small" title="配置坐标" @click.stop="openPointEdit(0, ci-1)">+</button>
</div>
<!-- 中间循环: 机器行 ri + 点位行 ri (ri from 1 to rows-1) -->
<template v-for="ri in (missionConfig.rows - 1)" :key="'mr'+ri">
<!-- 机器行 ri -->
<div class="grid-cell grid-header">机器行 {% raw %}{{ ri }}{% endraw %}</div>
<div v-for="(ci) in missionConfig.cols" :key="'m'+ri+'_'+ci"
class="grid-cell"
:class="{ active: getMachineAt(ri-1, ci-1) }"
@click="onCellClick(ri-1, ci-1)">
<template v-if="getMachineAt(ri-1, ci-1)">
<div class="cell-machine"></div>
</template>
<span v-else class="empty-cell"></span>
</div>
<!-- 点位行 ri+1 (pointRow=ri): 上面机器的背面 / 下面机器的正面 -->
<div class="grid-cell grid-header">点位行 {% raw %}{{ ri+1 }}{% endraw %}</div>
<div v-for="(ci) in missionConfig.cols" :key="'p'+(ri)+'_'+ci"
class="grid-cell point-cell"
@click="openPointEdit(ri, ci-1)">
<span class="point-coords">{% raw %}{{ getPointAt(ri, ci-1)?.coords?.[0]?.toFixed(1) || '-' }},{{ getPointAt(ri, ci-1)?.coords?.[1]?.toFixed(1) || '-' }}{% endraw %}</span>
<button class="btn-icon-small" title="配置坐标" @click.stop="openPointEdit(ri, ci-1)">+</button>
</div>
</template>
<!-- 最后一个机器行 (机器行 rows) -->
<div class="grid-cell grid-header">机器行 {% raw %}{{ missionConfig.rows }}{% endraw %}</div>
<div v-for="(ci) in missionConfig.cols" :key="'m'+missionConfig.rows+'_'+ci"
class="grid-cell"
:class="{ active: getMachineAt(missionConfig.rows-1, ci-1) }"
@click="onCellClick(missionConfig.rows-1, ci-1)">
<template v-if="getMachineAt(missionConfig.rows-1, ci-1)">
<div class="cell-machine"></div>
</template>
<span v-else class="empty-cell"></span>
</div>
<!-- 最后一个点位行 (pointRow=rows): 所有机器的背面拍摄点 -->
<div class="grid-cell grid-header">点位行 {% raw %}{{ missionConfig.rows+1 }}{% endraw %}</div>
<div v-for="(ci) in missionConfig.cols" :key="'p'+missionConfig.rows+'_'+ci"
class="grid-cell point-cell"
@click="openPointEdit(missionConfig.rows, ci-1)">
<span class="point-coords">{% raw %}{{ getPointAt(missionConfig.rows, ci-1)?.coords?.[0]?.toFixed(1) || '-' }},{{ getPointAt(missionConfig.rows, ci-1)?.coords?.[1]?.toFixed(1) || '-' }}{% endraw %}</span>
<button class="btn-icon-small" title="配置坐标" @click.stop="openPointEdit(missionConfig.rows, ci-1)">+</button>
</div>
</div>
<p class="hint" style="margin-top:8px">点击「点位行」配置拍摄坐标;点击「机器行」切换有无机器<br>中间点位同时服务于上下两台机器(上机器背面 / 下机器正面),删除机器不影响点位配置</p>
</div>
</section>
<!-- 中:选中机器的配置 -->
<section class="card" v-if="selectedMachine && selectedMachine.front && selectedMachine.back" style="margin-top:16px">
<h2>② 点位配置 — 第{% raw %}{{ selectedMachine.row+1 }}{% endraw %}行 第{% raw %}{{ selectedMachine.col+1 }}{% endraw %}列 <button class="btn btn-small" @click="clearSelection()">← 返回</button></h2>
<!-- 正面点位 -->
<div class="machine-form">
<h3>📷 正面点位</h3>
<div class="form-row">
<div class="form-group">
<label>X 坐标</label>
<input type="number" step="0.01" v-model.number="selectedMachine.front.coords[0]" placeholder="0.00">
</div>
<div class="form-group">
<label>Y 坐标</label>
<input type="number" step="0.01" v-model.number="selectedMachine.front.coords[1]" placeholder="0.00">
</div>
<div class="form-group">
<label>Yaw (弧度)</label>
<input type="number" step="0.01" v-model.number="selectedMachine.front.coords[2]" placeholder="0.00">
</div>
<div class="form-group" style="align-self:end">
<button class="btn btn-small btn-primary" @click="readPosition('front')" :disabled="!agvConnected">📍 读取当前位置</button>
</div>
</div>
<!-- 正面姿态列表 -->
<div v-if="selectedMachine.front.poses && selectedMachine.front.poses.length > 0" class="pose-list">
<h4>正面姿态 ({% raw %}{{ selectedMachine.front.poses.length }}{% endraw %} 个)</h4>
<div v-for="pose in selectedMachine.front.poses" :key="pose.id" class="pose-item">
<span class="pose-name">{% raw %}{{ pose.name }}{% endraw %}</span>
<span class="pose-angles" v-if="pose.arm_angles">角度: {% raw %}{{ formatAngles(pose.arm_angles) }}{% endraw %}</span>
<button class="btn-icon" @click="deletePose(selectedMachine.id, 'front', pose.id)">🗑️</button>
</div>
</div>
<div class="pose-add">
<input type="text" v-model="poseForm.name" placeholder="姿态名称(如:正面全景)">
<button class="btn btn-small btn-success" @click="addPoseToMachine(selectedMachine.id, 'front')"> 添加姿态</button>
</div>
</div>
<!-- 背面点位 -->
<div class="machine-form" style="margin-top:16px">
<h3>📷 背面点位</h3>
<div class="form-row">
<div class="form-group">
<label>X 坐标</label>
<input type="number" step="0.01" v-model.number="selectedMachine.back.coords[0]" placeholder="0.00">
</div>
<div class="form-group">
<label>Y 坐标</label>
<input type="number" step="0.01" v-model.number="selectedMachine.back.coords[1]" placeholder="0.00">
</div>
<div class="form-group">
<label>Yaw (弧度)</label>
<input type="number" step="0.01" v-model.number="selectedMachine.back.coords[2]" placeholder="0.00">
</div>
<div class="form-group" style="align-self:end">
<button class="btn btn-small btn-primary" @click="readPosition('back')" :disabled="!agvConnected">📍 读取当前位置</button>
</div>
</div>
<!-- 背面姿态列表 -->
<div v-if="selectedMachine.back.poses && selectedMachine.back.poses.length > 0" class="pose-list">
<h4>背面姿态 ({% raw %}{{ selectedMachine.back.poses.length }}{% endraw %} 个)</h4>
<div v-for="pose in selectedMachine.back.poses" :key="pose.id" class="pose-item">
<span class="pose-name">{% raw %}{{ pose.name }}{% endraw %}</span>
<span class="pose-angles" v-if="pose.arm_angles">角度: {% raw %}{{ formatAngles(pose.arm_angles) }}{% endraw %}</span>
<button class="btn-icon" @click="deletePose(selectedMachine.id, 'back', pose.id)">🗑️</button>
</div>
</div>
<div class="pose-add">
<input type="text" v-model="poseForm.name" placeholder="姿态名称(如:背面细节)">
<button class="btn btn-small btn-success" @click="addPoseToMachine(selectedMachine.id, 'back')"> 添加姿态</button>
</div>
</div>
<div class="btn-row" style="margin-top:16px">
<button class="btn btn-danger" @click="deleteMachine(selectedMachine.id)">🗑️ 删除此机器</button>
<button class="btn btn-secondary" @click="saveMachineCoords">💾 保存此机器配置</button>
</div>
</section>
<!-- 下:序列预览 -->
<section class="card" v-if="sequence && sequence.length > 0" style="margin-top:16px">
<h2>③ 🐍 蛇形拍摄序列预览</h2>
<div class="sequence-preview">
<div v-for="(step, idx) in sequence" :key="idx" class="sequence-step">
<span class="step-index">{% raw %}{{ idx+1 }}{% endraw %}</span>
<span class="step-info">
第{% raw %}{{ step.row+1 }}{% endraw %}行 第{% raw %}{{ step.col+1 }}{% endraw %}列
<span class="step-side" :class="step.side">{% raw %}{{ step.side === 'front' ? '正面' : '背面' }}{% endraw %}</span>
</span>
</div>
</div>
<div class="btn-row" style="margin-top:12px">
<button class="btn btn-secondary" @click="refreshSequence">🔄 刷新序列</button>
</div>
</section>
</div>
<!-- 点位编辑弹窗(基于独立点位行模型) -->
<div v-if="editingPoint" class="modal-overlay" @click.self="closePointEdit">
<div class="modal-box" style="min-width:460px">
<div style="display:flex;align-items:center;justify-content:space-between;margin-bottom:14px">
<h3 style="margin:0">📍 点位配置 — {% raw %}{{ getPointOwnerLabel(editingPoint.pointRow, editingPoint.col) }}{% endraw %}</h3>
<button class="btn-icon" @click="closePointEdit"></button>
</div>
<div style="margin-bottom:14px">
<div class="form-row">
<div class="form-group">
<label>X</label>
<input type="number" step="0.01" v-model.number="pointEditor.x" placeholder="0.00">
</div>
<div class="form-group">
<label>Y</label>
<input type="number" step="0.01" v-model.number="pointEditor.y" placeholder="0.00">
</div>
<div class="form-group">
<label>Yaw (rad)</label>
<input type="number" step="0.01" v-model.number="pointEditor.yaw" placeholder="0.00">
</div>
</div>
<div class="hint" style="margin-top:4px">
当前: ({% raw %}{{ pointEditor.x.toFixed(2) }}{% endraw %}, {% raw %}{{ pointEditor.y.toFixed(2) }}{% endraw %}, {% raw %}{{ pointEditor.yaw.toFixed(2) }}{% endraw %})
</div>
<div class="hint" style="margin-top:6px;font-size:12px;color:#888">
💡 此点位服务于: {% raw %}{{ getPointOwnerLabel(editingPoint.pointRow, editingPoint.col).split('·')[1] || '无' }}{% endraw %}
</div>
</div>
<div class="btn-row">
<button class="btn btn-primary" @click="loadPointFromAgv" :disabled="!agvConnected">📍 从AGV读取</button>
<button class="btn btn-success" @click="savePoint">💾 保存</button>
<button class="btn btn-warning" @click="clearPoint" :disabled="canClearPoint(editingPoint.pointRow, editingPoint.col)">🗑️ 清空</button>
<button class="btn btn-secondary" @click="closePointEdit">取消</button>
</div>
</div>
</div>
<!-- 机械臂控制 (保持不变) -->
<div v-if="tab === 'arm'">
<section class="card">
<h2>🤖 机械臂控制</h2>
<div v-if="!armConnected" class="alert alert-error">
⚠️ 机械臂未连接,请先在首页连接设备
</div>
<div v-else>
<div class="camera-preview">
<img :src="previewUrl" @error="onPreviewError">
</div>
<div class="joints-panel">
<h3>关节角度控制</h3>
<div class="joint-grid">
<div v-for="j in 6" :key="j" class="joint-control">
<label>J{% raw %}{{ j }}{% endraw %}</label>
<div class="joint-value">{% raw %}{{ currentAngles[j-1] ? currentAngles[j-1].toFixed(1) : '—' }}{% endraw %}°</div>
<div class="joint-buttons">
<button @mousedown="jogStart(j-1, -1)" @mouseup="jogStop(j-1)" @mouseleave="jogStop(j-1)"></button>
<input type="number" v-model.number="angleInputs[j-1]" step="0.5" @change="setAngle(j-1, angleInputs[j-1])">
<button @mousedown="jogStart(j-1, 1)" @mouseup="jogStop(j-1)" @mouseleave="jogStop(j-1)"></button>
</div>
</div>
</div>
<div class="btn-row">
<button class="btn btn-primary" @click="refreshAngles">🔄 刷新角度</button>
<button class="btn btn-secondary" @click="applyAngles">✅ 应用角度</button>
</div>
</div>
</div>
</section>
</div>
<!-- AGV 移动控制 (保持不变) -->
<div v-if="tab === 'agv'">
<section class="card">
<h2>🚗 AGV 移动控制</h2>
<div v-if="!agvConnected" class="alert alert-error">
⚠️ AGV 未连接,请先在首页连接设备
</div>
<div v-else>
<div v-show="cameraOpened" class="camera-preview" style="margin-bottom:16px">
<img :src="agvCameraUrl" style="width:100%;max-width:480px;aspect-ratio:16/9;object-fit:cover;border-radius:8px" @error="agvCameraUrl=''">
</div>
<div class="agv-status-bar">
<span>🔋 电压: <strong>{% raw %}{{ agvBattery !== null ? agvBattery + 'V' : '—' }}{% endraw %}</strong></span>
<span v-if="agvPosition">📍 位置: <strong>X={% raw %}{{ agvPosition[0] ? agvPosition[0].toFixed(2) : '?' }}{% endraw %} Y={% raw %}{{ agvPosition[1] ? agvPosition[1].toFixed(2) : '?' }}{% endraw %}</strong></span>
<button class="btn btn-small" @click="refreshAgvPosition">🔄 刷新</button>
</div>
<div class="agv-control-panel">
<div class="agv-dir-row">
<div class="agv-dir-placeholder"></div>
<button class="agv-btn agv-btn-up" @mousedown="agvMoveStart('forward')" @mouseup="agvMoveStop" @mouseleave="agvMoveStop">⬆️ 前进</button>
<div class="agv-dir-placeholder"></div>
</div>
<div class="agv-dir-row">
<button class="agv-btn agv-btn-left" @mousedown="agvMoveStart('left')" @mouseup="agvMoveStop" @mouseleave="agvMoveStop">↺ 左转</button>
<button class="agv-btn agv-btn-stop" @click="agvStop">🛑</button>
<button class="agv-btn agv-btn-right" @mousedown="agvMoveStart('right')" @mouseup="agvMoveStop" @mouseleave="agvMoveStop">↻ 右转</button>
</div>
<div class="agv-dir-row">
<div class="agv-dir-placeholder"></div>
<button class="agv-btn agv-btn-down" @mousedown="agvMoveStart('backward')" @mouseup="agvMoveStop" @mouseleave="agvMoveStop">⬇️ 后退</button>
<div class="agv-dir-placeholder"></div>
</div>
</div>
<div class="agv-lateral-row">
<button class="agv-btn agv-btn-lateral" @mousedown="agvMoveStart('left_lateral')" @mouseup="agvMoveStop" @mouseleave="agvMoveStop">⬅️ 向左平移</button>
<button class="agv-btn agv-btn-lateral" @mousedown="agvMoveStart('right_lateral')" @mouseup="agvMoveStop" @mouseleave="agvMoveStop">向右平移 ➡️</button>
</div>
<div class="form-row" style="margin-top:16px; max-width:400px">
<div class="form-group">
<label>移动速度</label>
<div class="speed-control">
<input type="range" v-model.number="agvSpeed" min="0.1" max="1.0" step="0.1" style="flex:1">
<span class="speed-value">{% raw %}{{ (agvSpeed * 100).toFixed(0) }}{% endraw %}%</span>
</div>
</div>
</div>
<div class="btn-row" style="margin-top:12px">
<button class="btn btn-danger" @click="agvResetCollision">🔄 撞物体后复位</button>
<button class="btn btn-secondary" @click="agvStop">🛑 立即停止</button>
</div>
</div>
</section>
</div>
</main>
</div>
<script src="/static/js/vue3.global.prod.js?v=20260513b"></script>
<script src="/static/js/setting.js?v=20260514k"></script>
</body>
</html>
+438
View File
@@ -0,0 +1,438 @@
const { createApp } = Vue
const API = ''
createApp({
data() {
return {
tab: 'map',
// 任务配置
missionConfig: { rows: 3, cols: 3, grid: [], machines: [] },
selectedMachine: null,
// 地图
mapForm: { map_dir: '/home/elephant/agv_pro_ros2/src/agv_pro_navigation2/map/', map_file: 'map.yaml' },
mapMsg: '',
mapLoaded: false,
mapImageUrl: '',
mapMeta: null,
// 点位
points: [],
newPointName: '',
newPointMode: 'front',
newPointSequence: ['front', 'back'],
// 机型(姿态组)
models: [],
selectedModelId: null,
newModelName: '',
newModelSerial: '',
poseForm: {},
// 机械臂
armConnected: false,
currentAngles: [],
angleInputs: [],
previewUrl: API + '/api/camera/preview',
jogIntervals: {},
// AGV
cameraOpened: false,
agvConnected: false,
agvBattery: null,
agvPosition: null,
agvSpeed: 0.5,
agvMoveInterval: null,
agvCameraUrl: API + '/api/camera/refresh',
agvCameraTimer: null,
}
},
mounted() {
this.refresh()
this.refreshAngles()
},
watch: {
tab(val) {
if (val === 'agv') {
this.agvCameraTimer = setInterval(() => {
this.agvCameraUrl = API + '/api/camera/refresh?t=' + Date.now()
}, 1000)
} else {
if (this.agvCameraTimer) {
clearInterval(this.agvCameraTimer)
this.agvCameraTimer = null
}
}
}
},
beforeUnmount() {
Object.values(this.jogIntervals).forEach(i => clearInterval(i))
if (this.agvCameraTimer) clearInterval(this.agvCameraTimer)
},
methods: {
async refresh() {
try {
const res = await fetch(API + '/api/status')
const data = await res.json()
this.agvConnected = data.agv_connected
this.armConnected = data.arm_connected
this.cameraOpened = data.camera_opened
this.mapLoaded = data.map_loaded
// 如果地图已加载,自动获取地图图像和元数据
if (data.map_loaded) {
this.mapImageUrl = API + '/api/map/image?t=' + Date.now()
try {
const metaRes = await fetch(API + '/api/map/meta')
const meta = await metaRes.json()
if (meta.ok) this.mapMeta = meta
} catch (e) {}
}
} catch (e) {}
await this.loadAllPoints()
await this.loadAllModels()
},
// === 地图 ===
async loadMap() {
const res = await fetch(API + '/api/map/load', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(this.mapForm)
})
const data = await res.json()
this.mapMsg = data.ok ? '✅ 地图加载成功' : '❌ ' + (data.error || '加载失败')
this.mapLoaded = data.ok
if (data.ok) {
this.mapImageUrl = API + '/api/map/image?t=' + Date.now()
try {
const metaRes = await fetch(API + '/api/map/meta')
const meta = await metaRes.json()
if (meta.ok) this.mapMeta = meta
} catch (e) {}
}
},
onMapError() {
this.mapMsg = '❌ 地图图像加载失败'
},
getMapX(coords) {
if (!coords || !this.mapMeta) return 50
const [x, y, yaw] = coords
const { resolution, origin, width } = this.mapMeta
const px = (x - origin[0]) / (resolution * width) * 100
return Math.max(0, Math.min(100, px))
},
getMapY(coords) {
if (!coords || !this.mapMeta) return 50
const [x, y, yaw] = coords
const { resolution, origin, height } = this.mapMeta
const py = (y - origin[1]) / (resolution * height) * 100
return Math.max(0, Math.min(100, 100 - py))
},
async saveMap() {
await fetch(API + '/api/map/save', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(this.mapForm)
})
this.mapMsg = '✅ 地图配置已保存'
},
// === 点位 ===
async loadAllPoints() {
const res = await fetch(API + '/api/points/list')
const data = await res.json()
this.points = data.points || []
},
async addPoint() {
const res = await fetch(API + '/api/points/add', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
name: this.newPointName || 'point_' + (this.points.length + 1),
photo_mode: this.newPointMode,
sequence: this.newPointSequence
})
})
const data = await res.json()
if (data.ok) {
await this.loadAllPoints()
this.newPointName = ''
}
},
async deletePoint(id) {
if (!confirm('确定删除该点位?')) return
await fetch(API + '/api/points/delete/' + id, { method: 'DELETE' })
await this.loadAllPoints()
},
async saveAllPoints() {
await fetch(API + '/api/points/save', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ points: this.points })
})
alert('点位已保存')
},
getPoint(id) {
return this.points.find(p => p.id === id)
},
formatAngles(angles) {
if (!angles) return '—'
return angles.map(a => (a || 0).toFixed(1) + '°').join(' / ')
},
// === 机型管理 ===
async loadAllModels() {
const res = await fetch(API + '/api/models/list')
const data = await res.json()
this.models = data.models || []
// 初始化 poseForm
this.models.forEach(m => {
if (!this.poseForm[m.id]) {
this.poseForm[m.id] = { name: '', photo_type: 'front', description: '' }
}
})
},
async addModel() {
const res = await fetch(API + '/api/models/add', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
name: this.newModelName || 'model_' + (this.models.length + 1),
serial_prefix: this.newModelSerial,
description: ''
})
})
const data = await res.json()
if (data.ok) {
await this.loadAllModels()
this.newModelName = ''
this.newModelSerial = ''
}
},
async deleteModel(modelId) {
if (!confirm('确定删除该机型?其下所有姿态将被删除!')) return
await fetch(API + '/api/models/delete/' + modelId, { method: 'DELETE' })
await this.loadAllModels()
},
// === 姿态管理(属于机型)===
async addPose(modelId) {
const form = this.poseForm[modelId]
if (!form) return
await fetch(API + '/api/models/poses/add', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
model_id: modelId,
name: form.name || '姿态' + ((this.getModel(modelId)?.poses?.length || 0) + 1),
photo_type: form.photo_type,
arm_angles: this.currentAngles,
speed: 500,
description: form.description || ''
})
})
await this.loadAllModels()
form.name = ''
form.description = ''
},
async deletePose(modelId, poseId) {
if (!confirm('确定删除该姿态?')) return
await fetch(API + '/api/models/' + modelId + '/poses/' + poseId, { method: 'DELETE' })
await this.loadAllModels()
},
getModel(id) {
return this.models.find(m => m.id === id)
},
// === 机械臂 ===
clearSelection() { this.selectedMachine = null },
async saveMachineCoords() {
if (!this.selectedMachine) return
try {
const res = await fetch(API + '/api/mission/machines/' + this.selectedMachine.id, {
method: 'PATCH',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
front: this.selectedMachine.front,
back: this.selectedMachine.back
})
})
if (res.ok) {
this.mapMsg = '✅ 机器坐标已保存'
setTimeout(() => this.mapMsg = '', 2000)
} else {
alert('保存失败: ' + res.status)
}
} catch (e) { alert('保存失败: ' + e.message) }
},
selectMachine(machine) {
// 确保 front/back 永远有 coords 数组,避免 v-model 赋值失败
if (!machine.front) machine.front = { coords: [0, 0, 0], poses: [] }
else if (!Array.isArray(machine.front.coords)) machine.front.coords = [0, 0, 0]
if (!machine.back) machine.back = { coords: [0, 0, 0], poses: [] }
else if (!Array.isArray(machine.back.coords)) machine.back.coords = [0, 0, 0]
this.selectedMachine = machine
console.log('selectedMachine:', machine)
},
onCellClick(ri, ci) {
let m = this.getMachineAt(ri, ci)
if (!m) {
// 自动创建机器记录
this.createMachine(ri, ci).then(() => {
m = this.getMachineAt(ri, ci)
if (m) this.selectMachine(m)
})
} else {
this.selectMachine(m)
}
},
async createMachine(ri, ci) {
try {
const res = await fetch(API + '/api/mission/machines', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ row: ri, col: ci, front: { coords: [0, 0, 0], poses: [] }, back: { coords: [0, 0, 0], poses: [] } })
})
await this.loadAllMachines()
return res.ok
} catch (e) { alert('创建机器失败: ' + e.message); return false }
},
async loadAllMachines() {
try {
const res = await fetch(API + '/api/mission/machines')
const data = await res.json()
this.missionConfig.machines = data.machines || []
} catch (e) { console.error('加载机器列表失败', e) }
},
getMachineAt(ri, ci) {
if (!this.missionConfig.machines) return null
return this.missionConfig.machines.find(m => m.row === ri && m.col === ci) || null
},
getPositionAt(ri, ci) {
if (!this.missionConfig.machines) return null
const machine = this.getMachineAt(ri, ci)
if (!machine) return null
if (ri === 0) return machine.front
const prevMachine = this.getMachineAt(ri - 1, ci)
return prevMachine ? prevMachine.back : machine.front
},
async refreshAngles() {
if (!this.armConnected) return
try {
const res = await fetch(API + '/api/arm/get_angles')
const data = await res.json()
if (data.ok && data.angles) {
this.currentAngles = data.angles
this.angleInputs = [...data.angles]
}
} catch (e) {}
},
async setAngle(idx, val) {
await fetch(API + '/api/arm/set_angle', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ joint: 'J' + (idx + 1), angle: val })
})
},
async applyAngles() {
await fetch(API + '/api/arm/set_angles', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ angles: this.angleInputs, speed: 500 })
})
},
jogStart(idx, dir) {
const joint = 'J' + (idx + 1)
fetch(API + '/api/arm/jog', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ joint, direction: dir })
})
this.jogIntervals[idx] = setInterval(() => this.refreshAngles(), 200)
},
jogStop(idx) {
clearInterval(this.jogIntervals[idx])
const joint = 'J' + (idx + 1)
fetch(API + '/api/arm/jog', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ joint, direction: 0 })
})
setTimeout(() => this.refreshAngles(), 300)
},
onPreviewError(e) {
e.target.style.display = 'none'
},
// === AGV 控制 ===
async refreshAgvPosition() {
if (!this.agvConnected) return
try {
const res = await fetch(API + '/api/agv/position')
const data = await res.json()
if (data.ok) {
this.agvPosition = data.position
this.agvBattery = data.battery
}
} catch (e) {}
},
agvMoveStart(dir) {
if (!this.agvConnected) return
fetch(API + '/api/agv/move', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ direction: dir, speed: this.agvSpeed })
})
},
agvMoveStop() {
fetch(API + '/api/agv/move', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ direction: 'stop' })
})
},
async agvStop() {
await fetch(API + '/api/agv/stop', { method: 'POST' })
},
async agvResetCollision() {
if (!this.agvConnected) {
alert('AGV 未连接')
return
}
if (!confirm('确定执行撞物体后复位?')) return
try {
const res = await fetch(API + '/api/agv/reset', { method: 'POST' })
const data = await res.json()
if (data.ok) {
alert('✅ ' + data.message)
await this.refresh()
await this.refreshAgvPosition()
} else {
alert('❌ 复位失败: ' + (data.error || '未知错误'))
}
} catch (e) {
alert('❌ 复位请求失败: ' + e.message)
}
},
async capturePosition(ri, ci, side) {
if (!this.agvConnected) { alert('请先连接AGV'); return }
let machine = this.getMachineAt(ri, ci)
if (!machine) {
try {
const res = await fetch(API + '/api/mission/machines', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ row: ri, col: ci, front: { coords: [0, 0, 0], poses: [] }, back: { coords: [0, 0, 0], poses: [] } })
})
if (!res.ok) throw new Error('创建失败')
await this.loadAllMachines()
machine = this.getMachineAt(ri, ci)
} catch (e) { alert('创建机器失败: ' + e.message); return }
}
try {
const res = await fetch(API + '/api/agv/position')
const pos = await res.json()
let x = 0, y = 0, theta = 0
if (pos.position && Array.isArray(pos.position)) { x = pos.position[0]||0; y = pos.position[1]||0; theta = pos.position[2]||0 }
if (!machine) { machine = this.getMachineAt(ri, ci) }
if (!machine) { alert('机器记录不存在'); return }
if (side === 'front') { machine.front.coords = [x, y, theta] } else { machine.back.coords = [x, y, theta] }
await fetch(API + '/api/mission/machines/' + machine.id, {
method: 'PUT', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(machine)
})
alert((side==='front'?'正面':'背面')+'点位已更新: ('+x.toFixed(2)+','+y.toFixed(2)+','+theta.toFixed(2)+')')
} catch (e) { alert('读取位置失败: '+e.message) }
},
}
}).mount('#app')
View File
+161
View File
@@ -0,0 +1,161 @@
"""
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()
+236
View File
@@ -0,0 +1,236 @@
"""
AGV 导航控制模块 - 通过 ROS2 控制 AGV 运动
使用 ros2 CLI 命令进行通信,避免 rclpy 导入问题
"""
import time
import subprocess
import json
import logging
import math
from typing import Tuple, Optional, List
logger = logging.getLogger(__name__)
# ROS2 环境设置
ROS2_SETUP_CMD = "export ROS_DOMAIN_ID=1 && source ~/agv_pro_ros2/install/setup.bash"
class AGVController:
"""AGV 运动控制 - ROS2 版本"""
def __init__(self, device: str = "/dev/agvpro_controller", baudrate: int = 1000000):
self.device = device
self.baudrate = baudrate
self._connected = False
self._position = [0.0, 0.0, 0.0] # [x, y, yaw]
self._voltage = 0.0
self._ros2_available = False
def _run_ros2_cmd(self, cmd: str, timeout: float = 5.0) -> tuple:
"""执行 ros2 命令"""
full_cmd = f"bash -c '{ROS2_SETUP_CMD} && {cmd}'"
try:
result = subprocess.run(
full_cmd,
shell=True,
capture_output=True,
text=True,
timeout=timeout
)
return result.returncode, result.stdout.strip(), result.stderr.strip()
except subprocess.TimeoutExpired:
return -1, "", "Timeout"
except Exception as e:
return -1, "", str(e)
def connect(self) -> bool:
"""连接 AGV - 检查 ROS2 节点和 topic"""
try:
# 检查 agv_pro_node 是否运行
rc, out, err = self._run_ros2_cmd("ros2 node list")
if rc != 0:
logger.error(f"ROS2 节点列表获取失败: {err}")
return False
if "/agv_pro_node" not in out:
logger.error("agv_pro_node 未运行")
return False
# 检查 /odom topic
rc, out, err = self._run_ros2_cmd("ros2 topic list")
if "/odom" not in out:
logger.error("/odom topic 不存在")
return False
# 尝试获取一次位置数据
rc, out, err = self._run_ros2_cmd(
"timeout 5 ros2 topic echo /odom 2>timeout 10 ros2 topic echo /odom --once 2>/dev/null1 | head -1",
timeout=6
)
if rc == 0 and out:
self._connected = True
self._ros2_available = True
logger.info("AGV ROS2 连接成功")
return True
else:
# /odom 可能暂时没数据,但节点存在也算连接成功
self._connected = True
self._ros2_available = True
logger.info("AGV ROS2 连接成功 (节点存在,等待 odom 数据)")
return True
except Exception as e:
logger.error(f"AGV 连接失败: {e}")
return False
def is_connected(self) -> bool:
return self._connected
def _publish_cmd_vel(self, linear_x: float = 0.0, linear_y: float = 0.0, angular_z: float = 0.0):
"""发布速度命令到 /cmd_vel"""
# 直接执行,避免引号嵌套问题
msg = f'{{"linear": {{"x": {linear_x}, "y": {linear_y}, "z": 0.0}}, "angular": {{"x": 0.0, "y": 0.0, "z": {angular_z}}}}}'
full_cmd = f"bash -c '{ROS2_SETUP_CMD} && ros2 topic pub /cmd_vel geometry_msgs/msg/Twist \"{msg}\" --once'"
try:
result = subprocess.run(
full_cmd,
shell=True,
capture_output=True,
text=True,
timeout=5
)
if result.returncode != 0:
logger.warning(f"发布 cmd_vel 失败: {result.stderr.strip()}")
except subprocess.TimeoutExpired:
logger.warning("发布 cmd_vel 超时")
except Exception as e:
logger.warning(f"发布 cmd_vel 失败: {e}")
def move_forward(self, speed: float = 0.5, duration: float = None):
"""前进"""
if not self.is_connected():
return
self._publish_cmd_vel(linear_x=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._publish_cmd_vel(linear_x=-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._publish_cmd_vel(angular_z=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._publish_cmd_vel(angular_z=-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._publish_cmd_vel(linear_y=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._publish_cmd_vel(linear_y=-speed)
if duration:
time.sleep(duration)
self.stop()
def stop(self):
"""停止"""
if self.is_connected():
self._publish_cmd_vel(0, 0, 0)
def get_position(self) -> Optional[List[float]]:
"""获取 AGV 当前位置 [x, y, yaw]"""
if not self.is_connected():
return None
try:
# 从 /odom topic 获取位置
rc, out, err = self._run_ros2_cmd(
"timeout 5 ros2 topic echo /odom 2>timeout 10 ros2 topic echo /odom --once 2>/dev/null1 | head -1",
timeout=6
)
if rc == 0 and out:
# 解析 odom 消息 (YAML 格式)
# ros2 topic echo 输出可能含多个 --- 分隔的文档,只取第一个
import yaml
yaml_str = out.split('---')[0]
data = yaml.safe_load(yaml_str)
if data:
pos = data.get("pose", {}).get("pose", {}).get("position", {})
x = pos.get("x", 0.0)
y = pos.get("y", 0.0)
# 从四元数计算 yaw
orient = data.get("pose", {}).get("pose", {}).get("orientation", {})
qz = orient.get("z", 0.0)
qw = orient.get("w", 1.0)
yaw = math.atan2(2.0 * qw * qz, 1.0 - 2.0 * qz * qz)
self._position = [x, y, yaw]
return self._position
except Exception as e:
logger.debug(f"获取位置失败: {e}")
return None
def go_to_point(self, x: float, y: float, rz: float = None, speed: float = 0.5) -> bool:
"""移动到目标点(需要 ROS2 导航栈)"""
logger.warning("go_to_point 需要 ROS2 Nav2 支持,当前仅记录目标")
return True
def get_battery(self) -> Optional[float]:
"""获取电池电压"""
if not self.is_connected():
return None
try:
# 从 /voltage topic 获取电压
rc, out, err = self._run_ros2_cmd(
"timeout 5 ros2 topic echo /voltage 2>timeout 10 ros2 topic echo /voltage --once 2>/dev/null1 | head -1",
timeout=6
)
if rc == 0 and out:
# 解析电压消息(ros2 topic echo 可能输出多文档 YAML
import yaml
yaml_str = out.split('---')[0]
data = yaml.safe_load(yaml_str)
if data:
self._voltage = data.get("data", 0.0)
return self._voltage
except Exception as e:
logger.debug(f"获取电压失败: {e}")
return None
def disconnect(self):
self.stop()
self._connected = False
def __enter__(self):
self.connect()
return self
def __exit__(self, *args):
self.disconnect()
+161
View File
@@ -0,0 +1,161 @@
"""
机械臂通信客户端 - 通过 TCP 连接机械臂端 TCP 服务器
服务器再转发给 RoboFlow (630 Socket API)
"""
import socket
import time
import logging
from typing import List, Optional, Tuple
logger = logging.getLogger(__name__)
class ArmClient:
"""TCP 客户端,连接机械臂端的 arm_server"""
def __init__(self, host: str, port: int, timeout: float = 10):
self.host = host
self.port = port
self.timeout = timeout
self._sock: Optional[socket.socket] = None
def connect(self) -> bool:
"""建立 TCP 连接"""
try:
self._sock = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
self._sock.settimeout(self.timeout)
self._sock.connect((self.host, self.port))
logger.info(f"已连接到机械臂 {self.host}:{self.port}")
return True
except Exception as e:
logger.error(f"连接机械臂失败: {e}")
return False
def send_command(self, cmd: str) -> Tuple[bool, str]:
"""发送命令并接收响应"""
if not self._sock:
return False, "未连接"
try:
# 发送命令(自动加换行)
self._sock.sendall((cmd + "\n").encode("utf-8"))
# 接收响应
resp = self._sock.recv(1024).decode("utf-8").strip()
return True, resp
except socket.timeout:
return False, "命令超时"
except Exception as e:
return False, str(e)
def close(self):
if self._sock:
self._sock.close()
self._sock = None
def reconnect(self) -> bool:
self.close()
time.sleep(1)
return self.connect()
# ========== 封装机械臂命令 ==========
def get_angles(self) -> Tuple[bool, List[float]]:
"""获取所有关节角度"""
ok, resp = self.send_command("get_angles()")
if ok and resp.startswith("get_angles:["):
try:
# get_angles:[0.174, 0.520, ...] → list
nums = resp.split("[")[1].split("]")[0]
angles = [float(x) for x in nums.split(",")]
return True, angles
except:
return False, []
return False, []
def set_angles(self, angles: List[float], speed: int = 500) -> bool:
"""设置所有关节角度"""
if len(angles) != 6:
return False
cmd = f"set_angles({angles[0]:.2f},{angles[1]:.2f},{angles[2]:.2f},{angles[3]:.2f},{angles[4]:.2f},{angles[5]:.2f},{speed})"
ok, resp = self.send_command(cmd)
return ok and "ok" in resp
def set_angle(self, joint: str, angle: float, speed: int = 500) -> bool:
"""设置单个关节角度"""
cmd = f"set_angle({joint},{angle:.2f},{speed})"
ok, resp = self.send_command(cmd)
return ok and "ok" in resp
def jog_angle(self, joint: str, direction: int, speed: int = 500) -> bool:
"""连续调节关节角度(direction: -1负方向/0停止/1正方向)"""
cmd = f"jog_angle({joint},{direction},{speed})"
ok, resp = self.send_command(cmd)
return ok
def get_coords(self) -> Tuple[bool, List[float]]:
"""获取当前坐标和姿态 [x, y, z, rx, ry, rz]"""
ok, resp = self.send_command("get_coords()")
if ok and "get_coords:" in resp:
try:
nums = resp.split("[")[1].split("]")[0]
coords = [float(x) for x in nums.split(",")]
return True, coords
except:
return False, []
return False, []
def set_coords(self, coords: List[float], speed: int = 500) -> bool:
"""设置坐标和姿态"""
if len(coords) != 6:
return False
cmd = f"set_coords({coords[0]:.2f},{coords[1]:.2f},{coords[2]:.2f},{coords[3]:.2f},{coords[4]:.2f},{coords[5]:.2f},{speed})"
ok, resp = self.send_command(cmd)
return ok and "ok" in resp
def jog_coord(self, axis: str, direction: int, speed: int = 500) -> bool:
"""连续调节坐标轴"""
cmd = f"jog_coord({axis},{direction},{speed})"
ok, resp = self.send_command(cmd)
return ok
def power_on(self) -> bool:
ok, _ = self.send_command("power_on()")
return ok
def state_on(self) -> bool:
ok, _ = self.send_command("state_on()")
return ok
def state_off(self) -> bool:
ok, _ = self.send_command("state_off()")
return ok
def state_check(self) -> bool:
"""检查机械臂状态是否正常"""
ok, resp = self.send_command("state_check()")
return ok and resp == "state_check:1"
def check_running(self) -> bool:
"""检查机械臂是否在运行"""
ok, resp = self.send_command("check_running()")
return ok and resp == "check_running:1"
def wait_done(self, timeout: float = 30) -> bool:
"""等待上一条命令执行完成"""
start = time.time()
while time.time() - start < timeout:
ok, resp = self.send_command("check_running()")
if ok and resp == "check_running:0":
return True
time.sleep(0.5)
return False
def task_stop(self) -> bool:
ok, _ = self.send_command("task_stop()")
return ok
def __enter__(self):
self.connect()
return self
def __exit__(self, *args):
self.close()
+87
View File
@@ -0,0 +1,87 @@
"""
配置文件 - 所有可配置参数集中管理
"""
import os
# 基础路径(部署后对应 ~/work/agv_app
BASE_DIR = os.path.dirname(os.path.abspath(__file__))
# ========== AGV 参数 ==========
AGV_CONFIG = {
"device": "/dev/agvpro_controller",
"baudrate": 10000000,
"move_speed": 0.5,
"turn_speed": 0.5,
}
# ========== 机械臂 TCP 客户端 ==========
ARM_CONFIG = {
"host": "192.168.110.164",
"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": "http://192.168.110.164:5003/api/camera/preview",
}
# ========== HTTP 上传 ==========
UPLOAD_CONFIG = {
"url": "https://ts.zhijian168.com/prod-api/file/uploadImage",
"timeout": 30,
"max_retries": 3,
}
# ========== Flask 服务器 ==========
SERVER_CONFIG = {
"host": "0.0.0.0",
"port": 5000,
"secret_key": "agv630_secret_key_2024",
"debug": False,
}
# ========== 任务配置存储路径 ==========
DATA_DIR = os.path.join(BASE_DIR, "data")
os.makedirs(DATA_DIR, exist_ok=True)
# ========== 关节角度范围限制 ==========
JOINT_LIMITS = {
"J1": (-180.0, 180.0),
"J2": (-270.0, 90.0),
"J3": (-150.0, 150.0),
"J4": (-260.0, 80.0),
"J5": (-168.0, 168.0),
"J6": (-174.0, 174.0),
}
# ========== 机械臂默认速度 ==========
DEFAULT_ARM_SPEED = 500
# ========== 状态定义 ==========
class State:
SETTING = "setting"
RUNNING = "running"
PAUSED = "paused"
IDLE = "idle"
class PhotoType:
FRONT = "front"
BACK = "back"
NAMEPLATE = "nameplate"
+76
View File
@@ -0,0 +1,76 @@
"""
HTTP 上传模块 - 将图片上传到指定服务器
"""
import os
import time
import logging
import requests
from typing import Optional
import uuid
logger = logging.getLogger(__name__)
class ImageUploader:
"""图片上传器"""
def __init__(self, upload_url: str, timeout: int = 30, max_retries: int = 3):
self.upload_url = upload_url
self.timeout = timeout
self.max_retries = max_retries
def upload(self, image_path: str, serial_number: str, photo_index: int,
photo_type: str = "front") -> Optional[str]:
"""
上传单张图片
返回: 服务器返回的消息(成功时),失败返回 None
"""
if not os.path.exists(image_path):
logger.error(f"图片文件不存在: {image_path}")
return None
for attempt in range(self.max_retries):
try:
with open(image_path, "rb") as f:
files = {"file": (os.path.basename(image_path), f, "image/jpeg")}
data = {
"serialNumber": serial_number,
"index": photo_index
}
resp = requests.post(
self.upload_url,
files=files,
data=data,
timeout=self.timeout
)
if resp.status_code == 200:
logger.info(f"图片上传成功: {serialNumber} #{photo_index} ({photo_type})")
try:
return resp.json().get("msg", "success")
except:
return resp.text
else:
logger.warning(f"上传失败 [{resp.status_code}]: {resp.text[:100]}")
except requests.exceptions.RequestException as e:
logger.warning(f"上传异常 (尝试 {attempt+1}/{self.max_retries}): {e}")
if attempt < self.max_retries - 1:
time.sleep(2)
logger.error(f"图片上传最终失败: {image_path}")
return None
def upload_batch(self, image_paths: list, serial_number: str,
start_index: int = 0) -> dict:
"""批量上传图片"""
results = []
for i, path in enumerate(image_paths):
result = self.upload(path, serial_number, start_index + i)
results.append({
"index": start_index + i,
"path": path,
"success": result is not None,
"msg": result
})
return results
+663
View File
@@ -0,0 +1,663 @@
"""
地图导航模块 - 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=0 && 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
+231
View File
@@ -0,0 +1,231 @@
"""
任务调度器 - 管理拍摄任务的执行
"""
import os
import json
import time
import logging
from typing import List, Dict, Optional
from enum import Enum
from .arm_client import ArmClient
from .agv_controller import AGVController
from .qr_scanner import QRScanner
from .image_uploader import ImageUploader
logger = logging.getLogger(__name__)
class TaskStatus(Enum):
PENDING = "pending"
RUNNING = "running"
COMPLETED = "completed"
FAILED = "failed"
PAUSED = "paused"
class MissionExecutor:
"""任务执行器 - 负责按顺序执行点位拍摄任务"""
def __init__(self, config: dict):
self.config = config
self.status = TaskStatus.PENDING
self.current_point_index = 0
self.current_pose_index = 0
self.snapshot_serial_map = {} # {point_id: serial_number} 缓存已扫描的 serialNumber
# 初始化各模块
self.agv = AGVController(
device=config.get("device", "/dev/agvpro_controller"),
baudrate=config.get("baudrate", 1000000)
)
self.arm_client: Optional[ArmClient] = None
self.uploader = ImageUploader(
upload_url=config["upload_url"],
timeout=config.get("upload_timeout", 30),
max_retries=config.get("upload_retries", 3)
)
self.qr_scanner = QRScanner(device_index=config.get("camera_index", 0))
# ========== 连接管理 ==========
def connect_all(self) -> Dict[str, bool]:
"""连接 AGV、机械臂、摄像头"""
results = {}
# 连接 AGV
results["agv"] = self.agv.connect()
# 连接机械臂(通过 TCP
arm_cfg = self.config["arm"]
self.arm_client = ArmClient(arm_cfg["host"], arm_cfg["port"])
results["arm"] = self.arm_client.connect()
# 打开摄像头
results["camera"] = self.qr_scanner.open()
return results
def disconnect_all(self):
"""断开所有连接"""
if self.arm_client:
self.arm_client.close()
self.agv.disconnect()
self.qr_scanner.close()
# ========== 任务执行 ==========
def execute_mission(self, mission_data: dict) -> dict:
"""
执行一个完整任务一个地图的所有点位
mission_data: 包含点位列表的完整任务配置
返回执行报告
"""
self.status = TaskStatus.RUNNING
report = {
"total_points": len(mission_data.get("points", [])),
"completed": 0,
"failed": 0,
"details": []
}
points = mission_data.get("points", [])
for i, point in enumerate(points):
self.current_point_index = i
try:
result = self._execute_point(point)
report["details"].append(result)
if result["status"] == "completed":
report["completed"] += 1
else:
report["failed"] += 1
except Exception as e:
logger.error(f"点位 {i} 执行异常: {e}")
report["failed"] += 1
report["details"].append({
"point_index": i,
"point_name": point.get("name", f"point_{i}"),
"status": "failed",
"error": str(e)
})
self.status = TaskStatus.COMPLETED if report["failed"] == 0 else TaskStatus.PAUSED
return report
def _execute_point(self, point: dict) -> dict:
"""执行单个点位的拍摄"""
point_name = point.get("name", "unknown")
logger.info(f"开始执行点位: {point_name}")
result = {
"point_name": point_name,
"poses": []
}
# 1. AGV 移动到点位
coords = point.get("coords", {})
x, y = coords.get("x", 0), coords.get("y", 0)
logger.info(f"AGV 移动到 ({x}, {y})")
# TODO: 调用导航移动到目标点
time.sleep(1) # 模拟移动
# 2. 执行该点位的所有姿态
poses = point.get("poses", [])
for j, pose in enumerate(poses):
self.current_pose_index = j
pose_result = self._execute_pose(point, pose, j)
result["poses"].append(pose_result)
# 如果是"两者都要"类型,需要按顺序执行两台机器
if pose.get("type") == "both":
# 执行顺序由 pose.sequence 配置
sequence = pose.get("sequence", ["front_first"])
for step in sequence:
if step == "front":
self._capture_and_upload(point, pose, "front", j)
elif step == "back":
self._capture_and_upload(point, pose, "back", j)
else:
photo_type = pose.get("photo_type", "front")
self._capture_and_upload(point, pose, photo_type, j)
result["status"] = "completed"
return result
def _execute_pose(self, point: dict, pose: dict, pose_idx: int) -> dict:
"""执行单个姿态的拍摄"""
photo_type = pose.get("photo_type", "front")
camera_source = pose.get("camera", "agv") # agv 或 arm
# 如果需要机械臂运动
arm_angles = pose.get("arm_angles", None)
if arm_angles and self.arm_client:
self.arm_client.set_angles(arm_angles, speed=pose.get("speed", 500))
time.sleep(1) # 等待运动到位
return {
"pose_index": pose_idx,
"photo_type": photo_type,
"arm_angles": arm_angles,
"status": "ready"
}
def _capture_and_upload(self, point: dict, pose: dict, photo_type: str, pose_idx: int):
"""拍摄并上传"""
point_id = point.get("id", str(point))
# 确定 serialNumber
if photo_type == "front":
# 正面:从二维码获取 serialNumber
serial = self.qr_scanner.scan_with_retry(max_attempts=5, interval=0.5)
if not serial:
logger.warning(f"点位 {point.get('name')} 正面拍摄未扫描到二维码,跳过")
return
self.snapshot_serial_map[point_id] = serial
else:
# 背面:使用缓存的 serialNumber
serial = self.snapshot_serial_map.get(point_id)
if not serial:
logger.warning(f"点位 {point.get('name')} 背面拍摄但无缓存 serialNumber")
return
# 拍摄图片(AGV 端摄像头)
frame = self.qr_scanner.read_frame()
if frame is None:
logger.error("摄像头读取失败")
return
# 保存图片
photo_dir = os.path.join(os.path.dirname(__file__), "..", "photos")
os.makedirs(photo_dir, exist_ok=True)
photo_path = os.path.join(photo_dir, f"{serial}_{photo_type}_{int(time.time())}.jpg")
import cv2
cv2.imwrite(photo_path, frame)
# 上传
self.uploader.upload(photo_path, serial, pose_idx, photo_type)
logger.info(f"上传完成: {serial} {photo_type}")
# ========== 状态查询 ==========
def get_status(self) -> dict:
return {
"task_status": self.status.value,
"current_point": self.current_point_index,
"current_pose": self.current_pose_index,
"agv_connected": self.agv.is_connected(),
"arm_connected": self.arm_client is not None,
"camera_opened": self.qr_scanner._cap is not None and self.qr_scanner._cap.isOpened()
}
def pause(self):
self.status = TaskStatus.PAUSED
def resume(self):
self.status = TaskStatus.RUNNING
def stop(self):
if self.arm_client:
self.arm_client.task_stop()
self.agv.stop()
self.status = TaskStatus.PENDING
+99
View File
@@ -0,0 +1,99 @@
"""
二维码识别模块 - 使用 OpenCV 识别二维码获取 serialNumber
"""
import cv2
import time
import logging
import numpy as np
from typing import Optional, Tuple
logger = logging.getLogger(__name__)
# 尝试导入二维码识别库
try:
from pyzbar.pyzbar import decode as qr_decode
PYZBAR_AVAILABLE = True
except ImportError:
PYZBAR_AVAILABLE = False
logger.warning("pyzbar 未安装,尝试用 OpenCV 内置 QRCodeDetector")
class QRScanner:
"""二维码扫描器"""
def __init__(self, device_index: int = 0):
self.device_index = device_index
self._cap: Optional[cv2.VideoCapture] = None
self._qr_detector = cv2.QRCodeDetector() # OpenCV 内置二维码检测器
def open(self) -> bool:
"""打开摄像头"""
try:
# 强制 V4L2 后端,获取标准彩色格式(与 test/server.py 一致)
self._cap = cv2.VideoCapture(self.device_index, cv2.CAP_V4L2)
if self._cap.isOpened():
logger.info(f"摄像头 {self.device_index} 已打开 (V4L2)")
return True
else:
# fallback: 不指定后端
self._cap = cv2.VideoCapture(self.device_index)
if self._cap.isOpened():
logger.info(f"摄像头 {self.device_index} 已打开 (默认后端)")
return True
logger.error(f"无法打开摄像头 {self.device_index}")
return False
except Exception as e:
logger.error(f"摄像头打开失败: {e}")
return False
def close(self):
if self._cap:
self._cap.release()
self._cap = None
def read_frame(self) -> Optional[np.ndarray]:
"""读取一帧"""
if not self._cap or not self._cap.isOpened():
return None
ret, frame = self._cap.read()
if not ret:
return None
return frame
def detect_qr(self, frame: np.ndarray) -> Optional[str]:
"""从图像帧中检测二维码"""
if frame is None:
return None
try:
# OpenCV 内置二维码检测
data, vertices, _ = self._qr_detector.detectAndDecode(frame)
if data and len(data) > 0:
return data.strip()
except Exception as e:
logger.debug(f"二维码检测失败: {e}")
return None
def scan_once(self) -> Optional[str]:
"""扫描一次(读取一帧并检测)"""
frame = self.read_frame()
return self.detect_qr(frame)
def scan_with_retry(self, max_attempts: int = 5, interval: float = 0.5) -> Optional[str]:
"""多次扫描直到成功或达到最大次数"""
for i in range(max_attempts):
result = self.scan_once()
if result:
return result
time.sleep(interval)
return None
def get_preview_frame(self) -> Optional[np.ndarray]:
"""获取预览帧(用于界面显示)"""
return self.read_frame()
def __enter__(self):
self.open()
return self
def __exit__(self, *args):
self.close()
+1
View File
@@ -0,0 +1 @@
# 机械臂服务端
+263
View File
@@ -0,0 +1,263 @@
"""
机械臂服务端 - 机械臂端主程序
运行在 10.247.46.165 端口 5002 (TCP) + 5003 (视频流)
通过 TCP Socket 接收 AGV 发来的指令转发给 RoboFlow (630 Socket API)
同时通过 ffmpeg 提供 HTTP 视频流
"""
import socket
import threading
import time
import logging
import os
import sys
import subprocess
from flask import Flask, Response, jsonify
from werkzeug.serving import make_server
# 添加当前目录到路径
sys.path.insert(0, os.path.dirname(os.path.abspath(__file__)))
# 配置日志
logging.basicConfig(
level=logging.INFO,
format="%(asctime)s [%(levelname)s] %(name)s: %(message)s",
handlers=[
logging.StreamHandler(),
logging.FileHandler(os.path.expanduser("~/work/arm_server/server.log"))
]
)
logger = logging.getLogger("arm_server")
# ========== Flask HTTP 服务器 - 视频流 (ffmpeg) ==========
arm_video_app = Flask(__name__)
ARM_CAMERA_INDEX = 0 # 机械臂端摄像头设备号
_ffmpeg_proc = None
@arm_video_app.route("/api/camera/preview")
def arm_camera_preview():
"""机械臂摄像头 MJPEG 流 (ffmpeg)"""
global _ffmpeg_proc
def generate():
global _ffmpeg_proc
# 启动 ffmpeg 进程(如果尚未运行)
if _ffmpeg_proc is None or _ffmpeg_proc.poll() is not None:
logger.info(f"启动 ffmpeg 视频流 (Video{ARM_CAMERA_INDEX})")
_ffmpeg_proc = subprocess.Popen(
[
"ffmpeg",
"-f", "v4l2",
"-input_format", "mjpeg",
"-i", f"/dev/video{ARM_CAMERA_INDEX}",
"-vf", "rotate=PI",
"-q:v", "8",
"-f", "mjpeg",
"-"
],
stdout=subprocess.PIPE,
stderr=subprocess.DEVNULL,
)
time.sleep(0.5) # 等待 ffmpeg 初始化
try:
while True:
if _ffmpeg_proc is None or _ffmpeg_proc.poll() is not None:
break
jpeg = _ffmpeg_proc.stdout.read(65536)
if not jpeg:
break
yield (b"--frame\r\nContent-Type: image/jpeg\r\n\r\n" + jpeg + 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})
@arm_video_app.route("/api/camera/restart", methods=["POST"])
def arm_camera_restart():
"""重启视频流"""
global _ffmpeg_proc
if _ffmpeg_proc:
_ffmpeg_proc.terminate()
_ffmpeg_proc = None
return jsonify({"ok": True})
# ========== RoboFlow 630 Socket API 客户端 ==========
class RoboFlowClient:
"""通过 Socket 连接 RoboFlow 630 机械臂控制盒"""
def __init__(self, host: str = "127.0.0.1", port: int = 5001, timeout: float = 10):
self.host = host
self.port = port
self.timeout = timeout
self._sock: socket.socket = None
def connect(self) -> bool:
try:
self._sock = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
self._sock.settimeout(self.timeout)
self._sock.connect((self.host, self.port))
logger.info(f"已连接到 RoboFlow {self.host}:{self.port}")
return True
except Exception as e:
logger.error(f"连接 RoboFlow 失败: {e}")
return False
def send_recv(self, cmd: str) -> str:
"""发送命令并等待响应"""
if not self._sock:
raise ConnectionError("未连接到 RoboFlow")
try:
self._sock.sendall((cmd + "\n").encode("utf-8"))
resp = self._sock.recv(4096).decode("utf-8").strip()
return resp
except socket.timeout:
return "ERROR: timeout"
except Exception as e:
return f"ERROR: {e}"
def close(self):
if self._sock:
self._sock.close()
self._sock = None
def __enter__(self):
self.connect()
return self
def __exit__(self, *args):
self.close()
# ========== TCP 服务器 - 接收 AGV 指令 ==========
class AGVCommandServer:
"""TCP 服务器,接收 AGV 发来的指令"""
def __init__(self, host: str = "0.0.0.0", port: int = 5002):
self.host = host
self.port = port
self._sock: socket.socket = None
self._running = False
self.roboflow: RoboFlowClient = None
self._connect_roboflow()
def _connect_roboflow(self):
self.roboflow = RoboFlowClient()
if self.roboflow.connect():
logger.info("RoboFlow 连接成功")
else:
logger.warning("RoboFlow 连接失败,服务将以 limited 模式运行")
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:
if not self.roboflow or not self.roboflow._sock:
return f"ERROR: RoboFlow not connected"
try:
return self.roboflow.send_recv(cmd)
except Exception as e:
return f"ERROR: {e}"
def stop(self):
self._running = False
if self._sock:
try:
self._sock.close()
except:
pass
if self.roboflow:
self.roboflow.close()
logger.info("机械臂服务端已停止")
# ========== 入口 ==========
def main():
import signal
server = AGVCommandServer(port=5002)
# 启动 Flask 视频流服务(端口 5003)
arm_server_http = make_server("0.0.0.0", 5003, arm_video_app, threaded=True)
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
if _ffmpeg_proc:
_ffmpeg_proc.terminate()
server.stop()
arm_server_http.shutdown()
sys.exit(0)
signal.signal(signal.SIGINT, signal_handler)
signal.signal(signal.SIGTERM, signal_handler)
server.start()
if __name__ == "__main__":
main()
+3
View File
@@ -0,0 +1,3 @@
# 机械臂端依赖(最少依赖)
# RoboFlow 已在树莓派上运行,此端仅做透传
flask>=2.0
+5
View File
@@ -0,0 +1,5 @@
#!/bin/bash
# 启动机械臂服务端
cd ~/work/arm_server
python3 arm_server.py
+31
View File
@@ -0,0 +1,31 @@
#!/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
+23
View File
@@ -0,0 +1,23 @@
{
"agv": {
"ip": "10.247.46.236",
"ssh_user": "elephant",
"ssh_password": "Elephant",
"map_file": "map.yaml",
"map_dir": "/home/elephant"
},
"arm": {
"ip": "10.247.46.165",
"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
@@ -0,0 +1,24 @@
#!/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