init
This commit is contained in:
@@ -0,0 +1,2 @@
|
|||||||
|
/dist
|
||||||
|
/node_modules
|
||||||
@@ -0,0 +1,235 @@
|
|||||||
|
# AimBot Web
|
||||||
|
|
||||||
|
基于 Vue 3 的机器人远程控制前端,提供自动监控、手动遥控和 PID 调参三个功能页面。
|
||||||
|
|
||||||
|
## 技术栈
|
||||||
|
|
||||||
|
| 层 | 技术 |
|
||||||
|
|----|------|
|
||||||
|
| 框架 | Vue 3.5 (Composition API, `<script setup>`) |
|
||||||
|
| 构建 | Vite 5 + pnpm |
|
||||||
|
| 路由 | Vue Router 4 (Hash 模式) |
|
||||||
|
| ROS 通信 | roslibjs → rosbridge WebSocket |
|
||||||
|
| HTTP | 原生 fetch |
|
||||||
|
|
||||||
|
## 项目结构
|
||||||
|
|
||||||
|
```
|
||||||
|
src/
|
||||||
|
├── main.js # 入口,挂载 App + Router
|
||||||
|
├── App.vue # 根布局:顶部导航 + router-view
|
||||||
|
├── config.js # 集中配置(地址、话题、控制参数)
|
||||||
|
├── router/index.js # Hash 路由表
|
||||||
|
├── composables/
|
||||||
|
│ └── useRosbridge.js # roslibjs 封装 Hook
|
||||||
|
└── views/
|
||||||
|
├── AutoMode.vue # 自动模式页
|
||||||
|
├── ManualMode.vue # 手动遥控页
|
||||||
|
└── DebugMode.vue # 调试模式页
|
||||||
|
```
|
||||||
|
|
||||||
|
## 架构分层
|
||||||
|
|
||||||
|
```
|
||||||
|
┌─────────────────────────────────────────────────────────┐
|
||||||
|
│ View 层 (views/*.vue) │
|
||||||
|
│ - 页面 UI 与用户交互 │
|
||||||
|
│ - 调用 composable + config │
|
||||||
|
├─────────────────────────────────────────────────────────┤
|
||||||
|
│ Composable 层 (composables/useRosbridge.js) │
|
||||||
|
│ - 封装 roslibjs 连接 / 发布 / 生命周期 │
|
||||||
|
│ - 暴露响应式 connected 状态 │
|
||||||
|
├─────────────────────────────────────────────────────────┤
|
||||||
|
│ Config 层 (config.js) │
|
||||||
|
│ - 所有可配置常量集中管理 │
|
||||||
|
│ - 部署时只需修改此文件 │
|
||||||
|
├─────────────────────────────────────────────────────────┤
|
||||||
|
│ Router 层 (router/index.js) │
|
||||||
|
│ - Hash 路由,/auto → /manual → /debug │
|
||||||
|
│ - 默认重定向到 /auto │
|
||||||
|
└─────────────────────────────────────────────────────────┘
|
||||||
|
```
|
||||||
|
|
||||||
|
## 路由设计
|
||||||
|
|
||||||
|
| 路径 | 页面 | 说明 |
|
||||||
|
|------|------|------|
|
||||||
|
| `#/auto` | AutoMode | 默认页,MJPEG 视频流 |
|
||||||
|
| `#/manual` | ManualMode | 键盘遥控 + 视频流 |
|
||||||
|
| `#/debug` | DebugMode | PID 参数配置 + 激光开关 |
|
||||||
|
| `#/` | → 重定向到 `#/auto` | |
|
||||||
|
|
||||||
|
选用 Hash 路由 (`createWebHashHistory`) 是为了避免部署到 Nginx 后直接访问子路径出现 404。
|
||||||
|
|
||||||
|
## 页面详解
|
||||||
|
|
||||||
|
### 1. AutoMode(自动模式)
|
||||||
|
|
||||||
|
**功能:** 显示自动运行过程的实时画面。
|
||||||
|
|
||||||
|
**数据流:**
|
||||||
|
|
||||||
|
```
|
||||||
|
web_video_server ──MJPEG──► <img src> ──► 浏览器渲染
|
||||||
|
```
|
||||||
|
|
||||||
|
**交互:**
|
||||||
|
- 「切换到当前模式」按钮 → `POST /api/mode/switch { mode: "auto" }` → ROS2 后端切换工作模式
|
||||||
|
|
||||||
|
**MJPEG 连接管理:**
|
||||||
|
- 组件挂载时 `<img>` 加载 MJPEG 流
|
||||||
|
- `onBeforeRouteLeave` 钩子中清空 `img.src`,确保离开页面时立即释放 TCP 连接
|
||||||
|
- 路由跳转顺序:旧页面断连 → 新页面建连,避免单连接服务端的冲突
|
||||||
|
|
||||||
|
### 2. ManualMode(手动模式)
|
||||||
|
|
||||||
|
**功能:** 键盘遥控机器人底盘和云台,J 键控制激光,同时观看实时画面。
|
||||||
|
|
||||||
|
**数据流:**
|
||||||
|
|
||||||
|
```
|
||||||
|
键盘事件 (keydown/keyup)
|
||||||
|
│
|
||||||
|
├─► pressed Set (WASD/方向键 多键集合)
|
||||||
|
│ │
|
||||||
|
│ ▼
|
||||||
|
│ computeVelocities() ◄── config.js
|
||||||
|
│ │
|
||||||
|
│ ├─► { left, right } ──► /cmd_wheel (值变化时发送)
|
||||||
|
│ └─► { pitch, yaw } ──► /cmd_gimbal (值变化时发送)
|
||||||
|
│ │
|
||||||
|
│ ▼
|
||||||
|
│ rosbridge ──► ROS2 节点
|
||||||
|
│
|
||||||
|
└─► J 键 (!e.repeat)
|
||||||
|
│
|
||||||
|
▼
|
||||||
|
POST /api/laser { action: "on"|"off" } ──► ROS2 HTTP 服务
|
||||||
|
```
|
||||||
|
|
||||||
|
**键盘映射策略:**
|
||||||
|
|
||||||
|
```
|
||||||
|
W (+线速度) ↑ (+pitch 度/秒)
|
||||||
|
A (+角速度) D (-角速度) ← (+yaw 度/秒) → (-yaw 度/秒)
|
||||||
|
S (-线速度) ↓ (-pitch 度/秒)
|
||||||
|
|
||||||
|
底盘 (差速驱动) 云台 (角度速率)
|
||||||
|
J 激光 开/关
|
||||||
|
```
|
||||||
|
|
||||||
|
- **多键同时按下**:W+A 同时按 = 前进 + 左转,自动合成左右轮速
|
||||||
|
- **同轴冲突**:W+S 同时按 → 相互抵消,线速度为 0
|
||||||
|
- **值变化才发布**:比较当前速度与上次发布值,无变化则跳过,避免无效 rosbridge 消息
|
||||||
|
- **自动启停**:首个按键按下启动 `requestAnimationFrame` 循环,所有按键松开启自动停止(publish 值不变的帧直接跳过)
|
||||||
|
- **J 键防重复**:`e.repeat` 过滤,长按 J 不会反复切换激光
|
||||||
|
|
||||||
|
**差速运动学转换:**
|
||||||
|
|
||||||
|
```
|
||||||
|
left_speed = linear - angular × (wheel_separation / 2)
|
||||||
|
right_speed = linear + angular × (wheel_separation / 2)
|
||||||
|
```
|
||||||
|
|
||||||
|
### 3. DebugMode(调试模式)
|
||||||
|
|
||||||
|
**功能:** PID 参数在线调参,激光开关,模式切换。
|
||||||
|
|
||||||
|
**交互:**
|
||||||
|
- 顶部三个按钮可切换任意模式
|
||||||
|
- **激光开关按钮**:「激光: ON/OFF」点击切换,`POST /api/laser { action: "on"|"off" }`
|
||||||
|
- 12 个数字输入框分 4 组:
|
||||||
|
- 视觉环 Yaw 电机:Kp / Ki / Kd
|
||||||
|
- 视觉环 Pitch 电机:Kp / Ki / Kd
|
||||||
|
- 角度环 Yaw 电机:Kp / Ki / Kd
|
||||||
|
- 角度环 Pitch 电机:Kp / Ki / Kd
|
||||||
|
- 「发布参数」按钮 → rosbridge 发布 JSON 到 `/pid_config` 话题
|
||||||
|
|
||||||
|
**发布数据格式:**
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"visual_yaw": { "kp": 1.0, "ki": 0.1, "kd": 0.01 },
|
||||||
|
"visual_pitch": { "kp": 1.0, "ki": 0.1, "kd": 0.01 },
|
||||||
|
"angle_yaw": { "kp": 2.0, "ki": 0.2, "kd": 0.02 },
|
||||||
|
"angle_pitch": { "kp": 2.0, "ki": 0.2, "kd": 0.02 }
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
## useRosbridge — 核心 Composable
|
||||||
|
|
||||||
|
```
|
||||||
|
useRosbridge()
|
||||||
|
│
|
||||||
|
├─ connect(url) → 建立 WebSocket,绑定 connection/close/error 事件
|
||||||
|
├─ disconnect() → 手动断开
|
||||||
|
├─ publish(topic, type, data) → 发布消息(自动缓存 Topic 对象)
|
||||||
|
├─ connected (ref) → 响应式连接状态
|
||||||
|
│
|
||||||
|
└─ onUnmounted() → 组件销毁时自动断开
|
||||||
|
```
|
||||||
|
|
||||||
|
- 每次调用 `useRosbridge()` 创建独立的 ros 实例
|
||||||
|
- Topic 对象按 `"topic:messageType"` 缓存,避免重复创建
|
||||||
|
- 组件卸载 (`onUnmounted`) 自动关闭 WebSocket,无需手动清理
|
||||||
|
|
||||||
|
## config.js — 配置项说明
|
||||||
|
|
||||||
|
```js
|
||||||
|
HTTP_API_BASE // HTTP API 基础地址
|
||||||
|
ROSBRIDGE_URL // rosbridge WebSocket,格式 ws://host:9090
|
||||||
|
MJPEG_AUTO_URL // 自动模式 MJPEG 流地址
|
||||||
|
MJPEG_MANUAL_URL // 手动模式 MJPEG 流地址
|
||||||
|
CMD_WHEEL_TOPIC // 底盘轮速话题名
|
||||||
|
CMD_GIMBAL_TOPIC // 云台角速度话题名
|
||||||
|
PID_CONFIG_TOPIC // PID 参数话题名
|
||||||
|
MAX_LINEAR // 最大线速度 (m/s)
|
||||||
|
MAX_ANGULAR // 最大角速度 (rad/s)
|
||||||
|
WHEEL_SEPARATION // 底盘轮距 (m)
|
||||||
|
GIMBAL_PITCH_DEG_S // 云台 pitch 角度速率 (度/秒)
|
||||||
|
GIMBAL_YAW_DEG_S // 云台 yaw 角度速率 (度/秒)
|
||||||
|
```
|
||||||
|
|
||||||
|
## HTTP API 接口一览
|
||||||
|
|
||||||
|
| 接口 | 方法 | 请求体 | 说明 | 触发位置 |
|
||||||
|
|------|------|--------|------|----------|
|
||||||
|
| `/api/mode/switch` | POST | `{"mode": "auto"\|"manual"\|"debug"}` | 切换工作模式 | Auto/Manual/Debug 页面按钮 |
|
||||||
|
| `/api/laser` | POST | `{"action": "on"\|"off"}` | 激光开关 | Manual J 键, Debug 按钮 |
|
||||||
|
|
||||||
|
## 通信架构总览
|
||||||
|
|
||||||
|
```
|
||||||
|
┌─────────────────────────────────────────────────────────┐
|
||||||
|
│ Vue3 前端 │
|
||||||
|
│ │
|
||||||
|
│ AutoMode.vue ────► fetch POST /api/mode/switch │
|
||||||
|
│ ────► <img src="/stream?topic=..."> │
|
||||||
|
│ ManualMode.vue ─► roslibjs ws://host:9090 │
|
||||||
|
│ ──► <img src="/stream?topic=..."> │
|
||||||
|
│ ──► fetch POST /api/laser (J 键) │
|
||||||
|
│ DebugMode.vue ──► roslibjs ws://host:9090 │
|
||||||
|
│ ───► fetch POST /api/mode/switch │
|
||||||
|
│ ───► fetch POST /api/laser (按钮) │
|
||||||
|
└──────────┬──────────────┬───────────────────────────────┘
|
||||||
|
│ HTTP │ WebSocket + MJPEG
|
||||||
|
▼ ▼
|
||||||
|
┌─────────┐ ┌──────────────┐
|
||||||
|
│ Nginx │ │ rosbridge │
|
||||||
|
│ 反代 │ │ web_video │
|
||||||
|
└────┬────┘ │ _server │
|
||||||
|
│ └──────┬───────┘
|
||||||
|
▼ ▼
|
||||||
|
ROS2 HTTP ROS2 话题
|
||||||
|
模式切换 底盘 / 云台 / PID
|
||||||
|
激光开关
|
||||||
|
```
|
||||||
|
|
||||||
|
## 开发命令
|
||||||
|
|
||||||
|
```bash
|
||||||
|
pnpm install # 安装依赖
|
||||||
|
pnpm dev # 开发服务器 → http://localhost:5173
|
||||||
|
pnpm build # 生产构建 → dist/
|
||||||
|
pnpm preview # 预览构建产物
|
||||||
|
```
|
||||||
@@ -0,0 +1,571 @@
|
|||||||
|
# AimBot 上游服务器对接文档
|
||||||
|
|
||||||
|
## 整体架构
|
||||||
|
|
||||||
|
```
|
||||||
|
┌──────────────────────────────────────────────────────────┐
|
||||||
|
│ 浏览器 (Vue3 前端) │
|
||||||
|
│ - 自动模式:观看 MJPEG 视频流 │
|
||||||
|
│ - 手动模式:键盘遥控(WASD/方向键/J) + MJPEG 视频流 │
|
||||||
|
│ - 调试模式:PID 调参 + 激光开关 │
|
||||||
|
└──────┬──────────────┬──────────────┬─────────────────────┘
|
||||||
|
│ HTTP │ WebSocket │ HTTP (MJPEG)
|
||||||
|
▼ ▼ ▼
|
||||||
|
┌──────────────┐ ┌──────────┐ ┌───────────────────┐
|
||||||
|
│ Nginx │ │rosbridge │ │ web_video_server │
|
||||||
|
│ 静态文件 │ │ :9090 │ │ :8081 │
|
||||||
|
│ 反向代理 │ └────┬─────┘ └────────┬──────────┘
|
||||||
|
└──────┬───────┘ │ │
|
||||||
|
│ ▼ ▼
|
||||||
|
│ ┌────────────────────────────────┐
|
||||||
|
└─────►│ ROS2 节点 │
|
||||||
|
│ - /cmd_wheel (轮速控制) │
|
||||||
|
│ - /cmd_gimbal (云台控制) │
|
||||||
|
│ - /pid_config (PID参数) │
|
||||||
|
│ - /api/mode/switch (模式切换) │
|
||||||
|
│ - /api/laser (激光开关) │
|
||||||
|
└────────────────────────────────┘
|
||||||
|
```
|
||||||
|
|
||||||
|
## 一、数据结构详解
|
||||||
|
|
||||||
|
前端与 ROS2 之间通过 HTTP API 和 rosbridge WebSocket 两种通道通信。以下为所有接口和消息的完整数据格式。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### 1.1 HTTP API — 模式切换
|
||||||
|
|
||||||
|
前端发起模式切换请求,ROS2 后端需实现对应 HTTP 接口。
|
||||||
|
|
||||||
|
```
|
||||||
|
POST /api/mode/switch
|
||||||
|
Content-Type: application/json
|
||||||
|
```
|
||||||
|
|
||||||
|
**请求体:**
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"mode": "auto"
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
| 字段 | 类型 | 必填 | 说明 |
|
||||||
|
|------|------|------|------|
|
||||||
|
| `mode` | string | 是 | 目标模式,可选值: `"auto"` \| `"manual"` \| `"debug"` |
|
||||||
|
|
||||||
|
**成功响应 (200):**
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"code": 0,
|
||||||
|
"message": "ok"
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
**错误响应 (4xx/5xx):**
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"code": -1,
|
||||||
|
"message": "unknown mode: xxx"
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### 1.2 HTTP API — 激光开关
|
||||||
|
|
||||||
|
```
|
||||||
|
POST /api/laser
|
||||||
|
Content-Type: application/json
|
||||||
|
```
|
||||||
|
|
||||||
|
**请求体:**
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"action": "on"
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
| 字段 | 类型 | 必填 | 说明 |
|
||||||
|
|------|------|------|------|
|
||||||
|
| `action` | string | 是 | `"on"` 开激光, `"off"` 关激光 |
|
||||||
|
|
||||||
|
**成功响应 (200):**
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"code": 0,
|
||||||
|
"message": "ok"
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### 1.3 rosbridge WebSocket — 连接
|
||||||
|
|
||||||
|
```
|
||||||
|
URL: ws://<host>:9090
|
||||||
|
协议: rosbridge v2.0 (JSON)
|
||||||
|
连接后: 前端通过 roslibjs 自动完成协议握手
|
||||||
|
```
|
||||||
|
|
||||||
|
连接状态通过前端 UI 实时展示:
|
||||||
|
- `已连接` (绿色) — WebSocket 已建立,rosbridge 协议就绪
|
||||||
|
- `未连接` (红色) — 断开或错误
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### 1.4 ROS 话题 — `/cmd_wheel` (底盘轮速)
|
||||||
|
|
||||||
|
| 属性 | 值 |
|
||||||
|
|------|-----|
|
||||||
|
| 消息类型 | `std_msgs/Float32MultiArray` |
|
||||||
|
| 方向 | 前端 → ROS2 |
|
||||||
|
| 发布时机 | 手动模式下按 WASD, 值变化时发送; 松键后自动停发 |
|
||||||
|
| 发布频率 | 按需 (值变即发), 约 30~60 Hz |
|
||||||
|
|
||||||
|
**数据格式:**
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"layout": {
|
||||||
|
"dim": [],
|
||||||
|
"data_offset": 0
|
||||||
|
},
|
||||||
|
"data": [1.0, 0.5]
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
| 字段 | 类型 | 说明 |
|
||||||
|
|------|------|------|
|
||||||
|
| `data[0]` | float64 | 左轮线速度 (m/s), 正值前转, 负值后转 |
|
||||||
|
| `data[1]` | float64 | 右轮线速度 (m/s), 正值前转, 负值后转 |
|
||||||
|
|
||||||
|
**速度计算 (差速驱动运动学):**
|
||||||
|
|
||||||
|
```
|
||||||
|
给定:
|
||||||
|
linear = 线速度 (m/s), WASD 合成
|
||||||
|
angular = 角速度 (rad/s), WASD 合成
|
||||||
|
sep = 轮距 (m)
|
||||||
|
|
||||||
|
左轮速度 = linear - angular × (sep / 2)
|
||||||
|
右轮速度 = linear + angular × (sep / 2)
|
||||||
|
```
|
||||||
|
|
||||||
|
**典型值示例:**
|
||||||
|
|
||||||
|
| 操作 | data[0] (左) | data[1] (右) | 效果 |
|
||||||
|
|------|-------------|-------------|------|
|
||||||
|
| W (前进) | 0.5 | 0.5 | 直行前进 |
|
||||||
|
| S (后退) | -0.5 | -0.5 | 直行后退 |
|
||||||
|
| A (左转) | -0.1 | 0.1 | 原地左转 |
|
||||||
|
| D (右转) | 0.1 | -0.1 | 原地右转 |
|
||||||
|
| W+A (前左) | 0.4 | 0.6 | 前进 + 左转 |
|
||||||
|
| W+D (前右) | 0.6 | 0.4 | 前进 + 右转 |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### 1.5 ROS 话题 — `/cmd_gimbal` (云台角速度)
|
||||||
|
|
||||||
|
| 属性 | 值 |
|
||||||
|
|------|-----|
|
||||||
|
| 消息类型 | `geometry_msgs/Twist` |
|
||||||
|
| 方向 | 前端 → ROS2 |
|
||||||
|
| 发布时机 | 手动模式下按方向键, 值变化时发送; 松键后自动停发 |
|
||||||
|
| 发布频率 | 按需 (值变即发), 约 30~60 Hz |
|
||||||
|
|
||||||
|
**数据格式:**
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"linear": { "x": 0, "y": 0, "z": 0 },
|
||||||
|
"angular": { "x": 0, "y": 30.0, "z": -15.0 }
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
| 字段 | 类型 | 说明 |
|
||||||
|
|------|------|------|
|
||||||
|
| `linear` | Vector3 | 不使用, 始终为 0 |
|
||||||
|
| `angular.x` | float64 | 不使用, 始终为 0 |
|
||||||
|
| `angular.y` | float64 | **云台 pitch 角度速率 (度/秒)**. 正值抬头, 负值低头 |
|
||||||
|
| `angular.z` | float64 | **云台 yaw 角度速率 (度/秒)**. 正值左转, 负值右转 |
|
||||||
|
|
||||||
|
> **注意:** 虽然底层消息类型是 `geometry_msgs/Twist`(语义上是 rad/s),但前端注入的数值单位是 **度/秒**。ROS2 侧接收后如需 rad/s,需自行转换:`rad/s = deg/s × π / 180`。
|
||||||
|
|
||||||
|
**典型值示例:**
|
||||||
|
|
||||||
|
| 操作 | angular.y (pitch) | angular.z (yaw) | 效果 |
|
||||||
|
|------|-------------------|-----------------|------|
|
||||||
|
| ↑ | 30.0 | 0 | 云台抬头 |
|
||||||
|
| ↓ | -30.0 | 0 | 云台低头 |
|
||||||
|
| ← | 0 | 30.0 | 云台左转 |
|
||||||
|
| → | 0 | -30.0 | 云台右转 |
|
||||||
|
| ↑+← | 30.0 | 30.0 | 抬头+左转 |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### 1.6 ROS 话题 — `/pid_config` (PID 参数)
|
||||||
|
|
||||||
|
| 属性 | 值 |
|
||||||
|
|------|-----|
|
||||||
|
| 消息类型 | `std_msgs/String` |
|
||||||
|
| 方向 | 前端 → ROS2 |
|
||||||
|
| 发布时机 | 调试页面点击「发布参数」按钮 |
|
||||||
|
|
||||||
|
**数据格式 (JSON 字符串, 放在 `data` 字段中):**
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"data": "{\"visual_yaw\":{\"kp\":1.0,\"ki\":0.1,\"kd\":0.01},\"visual_pitch\":{\"kp\":1.0,\"ki\":0.1,\"kd\":0.01},\"angle_yaw\":{\"kp\":2.0,\"ki\":0.2,\"kd\":0.02},\"angle_pitch\":{\"kp\":2.0,\"ki\":0.2,\"kd\":0.02}}"
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
**解析后结构:**
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"visual_yaw": { "kp": 1.0, "ki": 0.1, "kd": 0.01 },
|
||||||
|
"visual_pitch": { "kp": 1.0, "ki": 0.1, "kd": 0.01 },
|
||||||
|
"angle_yaw": { "kp": 2.0, "ki": 0.2, "kd": 0.02 },
|
||||||
|
"angle_pitch": { "kp": 2.0, "ki": 0.2, "kd": 0.02 }
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
| 字段 | 类型 | 说明 |
|
||||||
|
|------|------|------|
|
||||||
|
| `visual_yaw.kp` | float64 | 视觉环 Yaw 电机 — 比例系数 |
|
||||||
|
| `visual_yaw.ki` | float64 | 视觉环 Yaw 电机 — 积分系数 |
|
||||||
|
| `visual_yaw.kd` | float64 | 视觉环 Yaw 电机 — 微分系数 |
|
||||||
|
| `visual_pitch.kp` | float64 | 视觉环 Pitch 电机 — 比例系数 |
|
||||||
|
| `visual_pitch.ki` | float64 | 视觉环 Pitch 电机 — 积分系数 |
|
||||||
|
| `visual_pitch.kd` | float64 | 视觉环 Pitch 电机 — 微分系数 |
|
||||||
|
| `angle_yaw.kp` | float64 | 角度环 Yaw 电机 — 比例系数 |
|
||||||
|
| `angle_yaw.ki` | float64 | 角度环 Yaw 电机 — 积分系数 |
|
||||||
|
| `angle_yaw.kd` | float64 | 角度环 Yaw 电机 — 微分系数 |
|
||||||
|
| `angle_pitch.kp` | float64 | 角度环 Pitch 电机 — 比例系数 |
|
||||||
|
| `angle_pitch.ki` | float64 | 角度环 Pitch 电机 — 积分系数 |
|
||||||
|
| `angle_pitch.kd` | float64 | 角度环 Pitch 电机 — 微分系数 |
|
||||||
|
|
||||||
|
ROS2 侧接收解析示例 (Python):
|
||||||
|
|
||||||
|
```python
|
||||||
|
import json
|
||||||
|
msg = json.loads(ros_msg.data) # ros_msg 是 std_msgs/String
|
||||||
|
visual_yaw_kp = msg["visual_yaw"]["kp"] # → 1.0
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### 1.7 MJPEG 视频流
|
||||||
|
|
||||||
|
| 属性 | 值 |
|
||||||
|
|------|-----|
|
||||||
|
| 协议 | HTTP multipart/x-mixed-replace |
|
||||||
|
| 帧格式 | JPEG 图像 |
|
||||||
|
| 提供方 | `web_video_server` |
|
||||||
|
| URL 模板 | `http://<host>:8081/stream?topic=<话题名>` |
|
||||||
|
|
||||||
|
**URL 参数:**
|
||||||
|
|
||||||
|
| 参数 | 必填 | 说明 |
|
||||||
|
|------|------|------|
|
||||||
|
| `topic` | 是 | ROS 图像话题名, 如 `/camera/image_raw` |
|
||||||
|
| `mode` | 否 | 前端附加参数, 标识当前页面,服务端可忽略 |
|
||||||
|
|
||||||
|
**请求头:**
|
||||||
|
|
||||||
|
```
|
||||||
|
GET /stream?topic=/camera/image_raw HTTP/1.1
|
||||||
|
Accept: image/jpeg
|
||||||
|
```
|
||||||
|
|
||||||
|
**响应流 (multipart/x-mixed-replace):**
|
||||||
|
|
||||||
|
```
|
||||||
|
HTTP/1.1 200 OK
|
||||||
|
Content-Type: multipart/x-mixed-replace; boundary=--boundary
|
||||||
|
|
||||||
|
--boundary
|
||||||
|
Content-Type: image/jpeg
|
||||||
|
Content-Length: 54321
|
||||||
|
|
||||||
|
<JPEG binary data>
|
||||||
|
--boundary
|
||||||
|
Content-Type: image/jpeg
|
||||||
|
Content-Length: 54102
|
||||||
|
|
||||||
|
<JPEG binary data>
|
||||||
|
...
|
||||||
|
```
|
||||||
|
|
||||||
|
**前端消费方式:**
|
||||||
|
|
||||||
|
```html
|
||||||
|
<!-- 浏览器原生支持 MJPEG, 直接 <img src="..."> 即可 -->
|
||||||
|
<img src="http://192.168.0.11/stream?topic=/camera/image_raw&mode=auto" />
|
||||||
|
```
|
||||||
|
|
||||||
|
**注意事项:**
|
||||||
|
|
||||||
|
| 事项 | 说明 |
|
||||||
|
|------|------|
|
||||||
|
| 单连接限制 | `web_video_server` 默认每客户端仅允许一个连接。前端通过 `onBeforeRouteLeave` 在页面切换前主动断开旧连接 |
|
||||||
|
| Nginx 缓冲 | 反代 MJPEG 时必须 `proxy_buffering off`, 否则画面延迟严重 |
|
||||||
|
| 跨域 | 同域部署时使用相对路径可避免 CORS; 跨域场景需 Nginx 添加 CORS 头或使用 `Access-Control-Allow-Origin` |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### 1.8 键盘映射速查表
|
||||||
|
|
||||||
|
**底盘控制 (WASD), 手动模式:**
|
||||||
|
|
||||||
|
| 按键 | e.code | 动作 |
|
||||||
|
|------|--------|------|
|
||||||
|
| W | `KeyW` | 线速度 +0.5 m/s |
|
||||||
|
| S | `KeyS` | 线速度 -0.5 m/s |
|
||||||
|
| A | `KeyA` | 角速度 +1.0 rad/s |
|
||||||
|
| D | `KeyD` | 角速度 -1.0 rad/s |
|
||||||
|
|
||||||
|
**云台控制 (方向键), 手动模式:**
|
||||||
|
|
||||||
|
| 按键 | e.code | 动作 |
|
||||||
|
|------|--------|------|
|
||||||
|
| ↑ | `ArrowUp` | pitch +30 度/秒 |
|
||||||
|
| ↓ | `ArrowDown` | pitch -30 度/秒 |
|
||||||
|
| ← | `ArrowLeft` | yaw +30 度/秒 |
|
||||||
|
| → | `ArrowRight` | yaw -30 度/秒 |
|
||||||
|
|
||||||
|
**激光开关:**
|
||||||
|
|
||||||
|
| 按键 | e.code | 动作 |
|
||||||
|
|------|--------|------|
|
||||||
|
| J | `KeyJ` | 激光 开/关 切换 |
|
||||||
|
|
||||||
|
**多键策略:**
|
||||||
|
|
||||||
|
- 同轴冲突 (W+S, A+D, ↑+↓, ←+→): 相互抵消, 净值为 0
|
||||||
|
- 异轴组合 (W+A 等): 同时生效, 合成运动不冲突
|
||||||
|
- 重复按键 (`e.repeat`): 仅 J 键过滤 repeat, WASD/方向键允许 repeat (持续按住)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### 1.9 可配置参数一览
|
||||||
|
|
||||||
|
```js
|
||||||
|
// src/config.js — 部署时按实际情况修改
|
||||||
|
|
||||||
|
HTTP_API_BASE = 'http://192.168.0.11' // HTTP API 基础地址
|
||||||
|
ROSBRIDGE_URL = 'ws://192.168.0.11:9090' // rosbridge WebSocket
|
||||||
|
MJPEG_AUTO_URL = '.../stream?topic=/camera/image_raw&mode=auto' // 自动模式视频流
|
||||||
|
MJPEG_MANUAL_URL = '.../stream?topic=/camera/image_raw&mode=manual' // 手动模式视频流
|
||||||
|
CMD_WHEEL_TOPIC = '/cmd_wheel' // 底盘轮速话题
|
||||||
|
CMD_GIMBAL_TOPIC = '/cmd_gimbal' // 云台角速度话题
|
||||||
|
PID_CONFIG_TOPIC = '/pid_config' // PID 参数话题
|
||||||
|
MAX_LINEAR = 0.5 // 最大线速度 (m/s)
|
||||||
|
MAX_ANGULAR = 1.0 // 最大角速度 (rad/s)
|
||||||
|
WHEEL_SEPARATION = 0.2 // 轮距 (m)
|
||||||
|
GIMBAL_PITCH_DEG_S = 30 // 云台 pitch 速率 (度/秒)
|
||||||
|
GIMBAL_YAW_DEG_S = 30 // 云台 yaw 速率 (度/秒)
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 二、Nginx 配置
|
||||||
|
|
||||||
|
Nginx 负责三件事:托管 Vue 前端静态文件、反代 MJPEG 视频流、反代 HTTP API。
|
||||||
|
|
||||||
|
### 2.1 前端部署
|
||||||
|
|
||||||
|
构建产物在 `dist/` 目录,部署到服务器后由 Nginx 直接托管:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# 本地构建
|
||||||
|
cd aimbot-web
|
||||||
|
pnpm build
|
||||||
|
|
||||||
|
# 上传到服务器
|
||||||
|
scp -r dist/* user@server:/var/www/aimbot/
|
||||||
|
```
|
||||||
|
|
||||||
|
### 2.2 Nginx 配置示例
|
||||||
|
|
||||||
|
```nginx
|
||||||
|
server {
|
||||||
|
listen 80;
|
||||||
|
server_name your-domain.com;
|
||||||
|
|
||||||
|
# 前端静态文件
|
||||||
|
root /var/www/aimbot;
|
||||||
|
index index.html;
|
||||||
|
|
||||||
|
# SPA 路由回退 — 所有非文件请求回 index.html
|
||||||
|
location / {
|
||||||
|
try_files $uri $uri/ /index.html;
|
||||||
|
}
|
||||||
|
|
||||||
|
# MJPEG 视频流反代 (web_video_server)
|
||||||
|
location /stream {
|
||||||
|
proxy_pass http://127.0.0.1:8081/stream;
|
||||||
|
proxy_http_version 1.1;
|
||||||
|
proxy_buffering off;
|
||||||
|
|
||||||
|
# MJPEG multipart 边界必须保持完整
|
||||||
|
proxy_set_header Host $host;
|
||||||
|
proxy_set_header X-Real-IP $remote_addr;
|
||||||
|
|
||||||
|
# 长连接支持
|
||||||
|
proxy_set_header Connection "";
|
||||||
|
chunked_transfer_encoding off;
|
||||||
|
|
||||||
|
# 超时配置 — 视频流需要长连接
|
||||||
|
proxy_read_timeout 86400s;
|
||||||
|
proxy_send_timeout 86400s;
|
||||||
|
}
|
||||||
|
|
||||||
|
# 模式切换 API (HTTP → ROS2 节点)
|
||||||
|
location /api/ {
|
||||||
|
proxy_pass http://127.0.0.1:8080/api/;
|
||||||
|
proxy_set_header Host $host;
|
||||||
|
proxy_set_header Content-Type application/json;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### 2.3 关键说明
|
||||||
|
|
||||||
|
| 配置项 | 说明 |
|
||||||
|
|--------|------|
|
||||||
|
| `proxy_buffering off` | MJPEG 流必须关闭缓冲,否则画面延迟严重 |
|
||||||
|
| `chunked_transfer_encoding off` | multipart 边界依赖原始编码 |
|
||||||
|
| `proxy_read_timeout 86400s` | 视频流是长连接,超时必须足够大 |
|
||||||
|
| `try_files $uri /index.html` | Vue Hash 路由支持,直接访问 /#/manual 等路径 |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 三、ROS2 环境配置
|
||||||
|
|
||||||
|
### 3.1 rosbridge (WebSocket 服务)
|
||||||
|
|
||||||
|
前端通过 roslibjs 直连 rosbridge WebSocket,不走 Nginx 反代。
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# 安装
|
||||||
|
sudo apt install ros-${ROS_DISTRO}-rosbridge-server
|
||||||
|
|
||||||
|
# 启动 (默认 ws://0.0.0.0:9090)
|
||||||
|
ros2 run rosbridge_server rosbridge_websocket
|
||||||
|
```
|
||||||
|
|
||||||
|
### 3.2 web_video_server (MJPEG 推流)
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# 安装
|
||||||
|
sudo apt install ros-${ROS_DISTRO}-web-video-server
|
||||||
|
|
||||||
|
# 启动 (默认 http://0.0.0.0:8081)
|
||||||
|
# 发布摄像头话题后自动生成 /stream?topic=<topic_name>
|
||||||
|
ros2 run web_video_server web_video_server
|
||||||
|
```
|
||||||
|
|
||||||
|
### 3.3 话题一览
|
||||||
|
|
||||||
|
rosbridge 发布/订阅的话题需要与前端 `config.js` 一致:
|
||||||
|
|
||||||
|
| 话题 | 消息类型 | 方向 | 用途 |
|
||||||
|
|------|----------|------|------|
|
||||||
|
| `/cmd_wheel` | `std_msgs/Float32MultiArray` | 前端→ROS2 | 左右轮速 `[left, right]` |
|
||||||
|
| `/cmd_gimbal` | `geometry_msgs/Twist` | 前端→ROS2 | 云台角度速度(单位度/秒) `angular.y`(pitch) `angular.z`(yaw) |
|
||||||
|
| `/pid_config` | `std_msgs/String` | 前端→ROS2 | PID 参数 JSON |
|
||||||
|
|
||||||
|
### 3.4 模式切换与激光 API
|
||||||
|
|
||||||
|
前端 HTTP POST → Nginx 反代 → ROS2 节点(用户自行实现 HTTP 端)。
|
||||||
|
|
||||||
|
需实现两个接口,完整数据格式见 [1.1](#11-http-api--模式切换) 和 [1.2](#12-http-api--激光开关):
|
||||||
|
|
||||||
|
| 接口 | 方法 | 说明 |
|
||||||
|
|------|------|------|
|
||||||
|
| `/api/mode/switch` | POST | `{"mode": "auto"|"manual"|"debug"}` |
|
||||||
|
| `/api/laser` | POST | `{"action": "on"|"off"}` |
|
||||||
|
|
||||||
|
此接口需由 ROS2 侧 HTTP 服务(如 FastAPI / Flask / ros2-web-bridge)接收并处理。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 四、MJPEG 视频流
|
||||||
|
|
||||||
|
详细格式见 [1.7 MJPEG 视频流](#17-mjpeg-视频流)。
|
||||||
|
|
||||||
|
### 启动与话题发布
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# 启动 web_video_server
|
||||||
|
ros2 run web_video_server web_video_server
|
||||||
|
|
||||||
|
# 确保摄像头话题有数据
|
||||||
|
ros2 run image_transport republish raw in:=/camera/image_raw out:=/camera/compressed
|
||||||
|
```
|
||||||
|
|
||||||
|
`web_video_server` 自动识别话题类型并推送 MJPEG。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 五、启动顺序
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# 1. 启动 ROS2 核心节点
|
||||||
|
ros2 launch your_robot bringup.launch.py
|
||||||
|
|
||||||
|
# 2. 启动 rosbridge
|
||||||
|
ros2 run rosbridge_server rosbridge_websocket &
|
||||||
|
|
||||||
|
# 3. 启动视频推流
|
||||||
|
ros2 run web_video_server web_video_server &
|
||||||
|
|
||||||
|
# 4. 启动模式切换 HTTP 服务(自行实现)
|
||||||
|
python3 mode_api_server.py &
|
||||||
|
|
||||||
|
# 5. 启动/重载 Nginx
|
||||||
|
sudo systemctl reload nginx
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 六、前端 config.js 配置
|
||||||
|
|
||||||
|
完整配置项说明见 [1.9 可配置参数一览](#19-可配置参数一览)。
|
||||||
|
|
||||||
|
部署前修改 `src/config.js` 中的地址以匹配实际服务器环境,然后重新 `pnpm build`。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 七、前端 Nginx 完整部署命令
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# 构建
|
||||||
|
cd aimbot-web && pnpm build
|
||||||
|
|
||||||
|
# 部署
|
||||||
|
sudo cp -r dist/* /var/www/aimbot/
|
||||||
|
sudo cp nginx-aimbot.conf /etc/nginx/sites-available/aimbot
|
||||||
|
sudo ln -s /etc/nginx/sites-available/aimbot /etc/nginx/sites-enabled/
|
||||||
|
sudo nginx -t && sudo systemctl reload nginx
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 八、故障排查
|
||||||
|
|
||||||
|
| 现象 | 检查项 |
|
||||||
|
|------|--------|
|
||||||
|
| MJPEG 不出画面 | `web_video_server` 是否运行,话题是否有数据,Nginx `proxy_buffering` 是否设为 `off` |
|
||||||
|
| rosbridge 连不上 | 防火墙是否开放 9090,`ROSBRIDGE_URL` 是否用 `ws://` 协议 |
|
||||||
|
| 按键无反应 | rosbridge 连接状态是否为"已连接",话题名是否与 ROS2 侧一致 |
|
||||||
|
| 模式切换报错 | `/api/mode/switch` 的 HTTP 服务是否实现并运行 |
|
||||||
|
| 激光开关无反应 | `/api/laser` 的 HTTP 服务是否实现,J 键或按钮是否正确触发 |
|
||||||
|
| 页面空白 | Nginx `try_files` 是否配了 `$uri /index.html` |
|
||||||
|
| 页面切换后画面不显示 | MJPEG 服务端是否单连接限制,检查 `onBeforeRouteLeave` 是否正常释放 |
|
||||||
+16
@@ -0,0 +1,16 @@
|
|||||||
|
<!DOCTYPE html>
|
||||||
|
<html lang="zh-CN">
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8" />
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||||
|
<title>AimBot Control</title>
|
||||||
|
<style>
|
||||||
|
* { margin: 0; padding: 0; box-sizing: border-box; }
|
||||||
|
body { background: #1a1a2e; color: #eee; font-family: 'Segoe UI', sans-serif; }
|
||||||
|
</style>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<div id="app"></div>
|
||||||
|
<script type="module" src="/src/main.js"></script>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
@@ -0,0 +1,20 @@
|
|||||||
|
{
|
||||||
|
"name": "aimbot-web",
|
||||||
|
"private": true,
|
||||||
|
"version": "1.0.0",
|
||||||
|
"type": "module",
|
||||||
|
"scripts": {
|
||||||
|
"dev": "vite",
|
||||||
|
"build": "vite build",
|
||||||
|
"preview": "vite preview"
|
||||||
|
},
|
||||||
|
"dependencies": {
|
||||||
|
"roslib": "^1.4.0",
|
||||||
|
"vue": "^3.4.0",
|
||||||
|
"vue-router": "^4.3.0"
|
||||||
|
},
|
||||||
|
"devDependencies": {
|
||||||
|
"@vitejs/plugin-vue": "^5.0.0",
|
||||||
|
"vite": "^5.4.0"
|
||||||
|
}
|
||||||
|
}
|
||||||
Generated
+1027
File diff suppressed because it is too large
Load Diff
+49
@@ -0,0 +1,49 @@
|
|||||||
|
<template>
|
||||||
|
<div class="app">
|
||||||
|
<nav class="navbar">
|
||||||
|
<router-link to="/auto">自动模式</router-link>
|
||||||
|
<router-link to="/manual">手动模式</router-link>
|
||||||
|
<router-link to="/debug">调试模式</router-link>
|
||||||
|
</nav>
|
||||||
|
<main class="main">
|
||||||
|
<router-view />
|
||||||
|
</main>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup>
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<style>
|
||||||
|
.app {
|
||||||
|
min-height: 100vh;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
}
|
||||||
|
.navbar {
|
||||||
|
display: flex;
|
||||||
|
gap: 0;
|
||||||
|
background: #16213e;
|
||||||
|
border-bottom: 1px solid #0f3460;
|
||||||
|
}
|
||||||
|
.navbar a {
|
||||||
|
padding: 12px 24px;
|
||||||
|
color: #a0a0b0;
|
||||||
|
text-decoration: none;
|
||||||
|
font-size: 14px;
|
||||||
|
transition: color 0.2s, background 0.2s;
|
||||||
|
border-bottom: 2px solid transparent;
|
||||||
|
}
|
||||||
|
.navbar a:hover {
|
||||||
|
color: #e0e0e0;
|
||||||
|
background: rgba(255,255,255,0.04);
|
||||||
|
}
|
||||||
|
.navbar a.router-link-active {
|
||||||
|
color: #e94560;
|
||||||
|
border-bottom-color: #e94560;
|
||||||
|
}
|
||||||
|
.main {
|
||||||
|
flex: 1;
|
||||||
|
padding: 20px;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
@@ -0,0 +1,41 @@
|
|||||||
|
import { ref, onUnmounted } from 'vue'
|
||||||
|
import ROSLIB from 'roslib'
|
||||||
|
|
||||||
|
export function useRosbridge() {
|
||||||
|
const connected = ref(false)
|
||||||
|
let ros = null
|
||||||
|
let topicCache = {}
|
||||||
|
|
||||||
|
function connect(url) {
|
||||||
|
if (ros) ros.close()
|
||||||
|
ros = new ROSLIB.Ros({ url })
|
||||||
|
ros.on('connection', () => { connected.value = true })
|
||||||
|
ros.on('close', () => { connected.value = false })
|
||||||
|
ros.on('error', (err) => { console.error('Rosbridge error:', err) })
|
||||||
|
}
|
||||||
|
|
||||||
|
function disconnect() {
|
||||||
|
if (ros) { ros.close(); ros = null }
|
||||||
|
connected.value = false
|
||||||
|
topicCache = {}
|
||||||
|
}
|
||||||
|
|
||||||
|
function getTopic(name, messageType) {
|
||||||
|
const key = `${name}:${messageType}`
|
||||||
|
if (!topicCache[key]) {
|
||||||
|
topicCache[key] = new ROSLIB.Topic({ ros, name, messageType })
|
||||||
|
}
|
||||||
|
return topicCache[key]
|
||||||
|
}
|
||||||
|
|
||||||
|
function publish(topic, messageType, data) {
|
||||||
|
const t = getTopic(topic, messageType)
|
||||||
|
t.publish(new ROSLIB.Message(data))
|
||||||
|
}
|
||||||
|
|
||||||
|
onUnmounted(() => {
|
||||||
|
if (ros) ros.close()
|
||||||
|
})
|
||||||
|
|
||||||
|
return { connected, connect, disconnect, publish }
|
||||||
|
}
|
||||||
@@ -0,0 +1,24 @@
|
|||||||
|
// 配置 — 按实际部署修改地址和话题
|
||||||
|
export const HTTP_API_BASE = 'http://192.168.0.11'
|
||||||
|
|
||||||
|
export const ROSBRIDGE_URL = 'ws://192.168.0.11:9090'
|
||||||
|
|
||||||
|
export const MJPEG_AUTO_URL = 'http://192.168.0.11/stream?topic=/vision/result_image&mode=auto'
|
||||||
|
export const MJPEG_MANUAL_URL = 'http://192.168.0.11/stream?topic=/vision/result_image&mode=manual'
|
||||||
|
|
||||||
|
// ROS 话题
|
||||||
|
export const CMD_WHEEL_TOPIC = '/cmd_wheel'
|
||||||
|
export const CMD_GIMBAL_TOPIC = '/cmd_gimbal'
|
||||||
|
export const PID_CONFIG_TOPIC = '/pid_config'
|
||||||
|
|
||||||
|
// 键盘控制参数
|
||||||
|
export const MAX_LINEAR = 0.5 // m/s
|
||||||
|
export const MAX_ANGULAR = 1.0 // rad/s
|
||||||
|
export const WHEEL_SEPARATION = 0.2 // 轮距(m)
|
||||||
|
|
||||||
|
// 云台角度速率 (度/秒)
|
||||||
|
export const GIMBAL_PITCH_DEG_S = 10
|
||||||
|
export const GIMBAL_YAW_DEG_S = 10
|
||||||
|
|
||||||
|
// 发布频率
|
||||||
|
export const PUBLISH_RATE = 30 // Hz
|
||||||
@@ -0,0 +1,5 @@
|
|||||||
|
import { createApp } from 'vue'
|
||||||
|
import App from './App.vue'
|
||||||
|
import router from './router/index.js'
|
||||||
|
|
||||||
|
createApp(App).use(router).mount('#app')
|
||||||
@@ -0,0 +1,16 @@
|
|||||||
|
import { createRouter, createWebHashHistory } from 'vue-router'
|
||||||
|
import AutoMode from '../views/AutoMode.vue'
|
||||||
|
import ManualMode from '../views/ManualMode.vue'
|
||||||
|
import DebugMode from '../views/DebugMode.vue'
|
||||||
|
|
||||||
|
const routes = [
|
||||||
|
{ path: '/auto', component: AutoMode },
|
||||||
|
{ path: '/manual', component: ManualMode },
|
||||||
|
{ path: '/debug', component: DebugMode },
|
||||||
|
{ path: '/', redirect: '/auto' },
|
||||||
|
]
|
||||||
|
|
||||||
|
export default createRouter({
|
||||||
|
history: createWebHashHistory(),
|
||||||
|
routes,
|
||||||
|
})
|
||||||
@@ -0,0 +1,60 @@
|
|||||||
|
<template>
|
||||||
|
<div class="auto-mode">
|
||||||
|
<div class="header">
|
||||||
|
<h2>自动模式</h2>
|
||||||
|
<button class="btn-switch" @click="switchToAuto">切换到当前模式</button>
|
||||||
|
</div>
|
||||||
|
<div class="stream-container">
|
||||||
|
<img ref="streamImg" :src="streamUrl" alt="MJPEG Stream" class="stream" />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup>
|
||||||
|
import { ref } from 'vue'
|
||||||
|
import { onBeforeRouteLeave } from 'vue-router'
|
||||||
|
import { MJPEG_AUTO_URL, HTTP_API_BASE } from '../config.js'
|
||||||
|
|
||||||
|
const streamUrl = MJPEG_AUTO_URL
|
||||||
|
const streamImg = ref(null)
|
||||||
|
|
||||||
|
onBeforeRouteLeave((_to, _from, next) => {
|
||||||
|
if (streamImg.value) streamImg.value.src = ''
|
||||||
|
next()
|
||||||
|
})
|
||||||
|
|
||||||
|
async function switchToAuto() {
|
||||||
|
try {
|
||||||
|
await fetch(`${HTTP_API_BASE}/api/mode/switch`, {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify({ mode: 'auto' }),
|
||||||
|
})
|
||||||
|
console.log('已切换到自动模式')
|
||||||
|
} catch (e) {
|
||||||
|
console.error('模式切换失败:', e)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
.auto-mode { display: flex; flex-direction: column; gap: 16px; }
|
||||||
|
.header { display: flex; align-items: center; gap: 20px; }
|
||||||
|
.header h2 { font-size: 18px; font-weight: 500; }
|
||||||
|
.btn-switch {
|
||||||
|
padding: 8px 20px;
|
||||||
|
border: none; border-radius: 4px;
|
||||||
|
background: #e94560; color: #fff;
|
||||||
|
font-size: 14px; cursor: pointer;
|
||||||
|
transition: background 0.2s;
|
||||||
|
}
|
||||||
|
.btn-switch:hover { background: #c73a52; }
|
||||||
|
.stream-container {
|
||||||
|
background: #000;
|
||||||
|
border-radius: 8px;
|
||||||
|
overflow: hidden;
|
||||||
|
display: flex;
|
||||||
|
justify-content: center;
|
||||||
|
}
|
||||||
|
.stream { max-width: 100%; height: auto; }
|
||||||
|
</style>
|
||||||
@@ -0,0 +1,166 @@
|
|||||||
|
<template>
|
||||||
|
<div class="debug-mode">
|
||||||
|
<div class="header">
|
||||||
|
<h2>调试模式</h2>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="mode-buttons">
|
||||||
|
<button class="btn-mode" @click="switchMode('auto')">切换到自动模式</button>
|
||||||
|
<button class="btn-mode" @click="switchMode('manual')">切换到手动模式</button>
|
||||||
|
<button class="btn-mode active" @click="switchMode('debug')">切换到调试模式</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="pid-grid">
|
||||||
|
<div class="pid-group">
|
||||||
|
<h3>视觉环 Yaw 电机</h3>
|
||||||
|
<label>Kp <input type="number" v-model="params.visualYaw.kp" step="0.001" /></label>
|
||||||
|
<label>Ki <input type="number" v-model="params.visualYaw.ki" step="0.001" /></label>
|
||||||
|
<label>Kd <input type="number" v-model="params.visualYaw.kd" step="0.001" /></label>
|
||||||
|
</div>
|
||||||
|
<div class="pid-group">
|
||||||
|
<h3>视觉环 Pitch 电机</h3>
|
||||||
|
<label>Kp <input type="number" v-model="params.visualPitch.kp" step="0.001" /></label>
|
||||||
|
<label>Ki <input type="number" v-model="params.visualPitch.ki" step="0.001" /></label>
|
||||||
|
<label>Kd <input type="number" v-model="params.visualPitch.kd" step="0.001" /></label>
|
||||||
|
</div>
|
||||||
|
<div class="pid-group">
|
||||||
|
<h3>角度环 Yaw 电机</h3>
|
||||||
|
<label>Kp <input type="number" v-model="params.angleYaw.kp" step="0.001" /></label>
|
||||||
|
<label>Ki <input type="number" v-model="params.angleYaw.ki" step="0.001" /></label>
|
||||||
|
<label>Kd <input type="number" v-model="params.angleYaw.kd" step="0.001" /></label>
|
||||||
|
</div>
|
||||||
|
<div class="pid-group">
|
||||||
|
<h3>角度环 Pitch 电机</h3>
|
||||||
|
<label>Kp <input type="number" v-model="params.anglePitch.kp" step="0.001" /></label>
|
||||||
|
<label>Ki <input type="number" v-model="params.anglePitch.ki" step="0.001" /></label>
|
||||||
|
<label>Kd <input type="number" v-model="params.anglePitch.kd" step="0.001" /></label>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="actions">
|
||||||
|
<span class="conn-status" :class="{ ok: connected }">
|
||||||
|
rosbridge: {{ connected ? '已连接' : '未连接' }}
|
||||||
|
</span>
|
||||||
|
<button class="btn-laser" @click="toggleLaser">
|
||||||
|
激光: {{ laserOn ? 'ON' : 'OFF' }}
|
||||||
|
</button>
|
||||||
|
<button class="btn-publish" @click="publishParams">发布参数</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup>
|
||||||
|
import { reactive, ref } from 'vue'
|
||||||
|
import { HTTP_API_BASE, ROSBRIDGE_URL, PID_CONFIG_TOPIC } from '../config.js'
|
||||||
|
import { useRosbridge } from '../composables/useRosbridge.js'
|
||||||
|
|
||||||
|
const { connected, connect, publish } = useRosbridge()
|
||||||
|
connect(ROSBRIDGE_URL)
|
||||||
|
|
||||||
|
const laserOn = ref(false)
|
||||||
|
|
||||||
|
const params = reactive({
|
||||||
|
visualYaw: { kp: 0, ki: 0, kd: 0 },
|
||||||
|
visualPitch: { kp: 0, ki: 0, kd: 0 },
|
||||||
|
angleYaw: { kp: 0, ki: 0, kd: 0 },
|
||||||
|
anglePitch: { kp: 0, ki: 0, kd: 0 },
|
||||||
|
})
|
||||||
|
|
||||||
|
async function switchMode(mode) {
|
||||||
|
try {
|
||||||
|
await fetch(`${HTTP_API_BASE}/api/mode/switch`, {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify({ mode }),
|
||||||
|
})
|
||||||
|
console.log(`已切换到${mode}模式`)
|
||||||
|
} catch (e) {
|
||||||
|
console.error('模式切换失败:', e)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function publishParams() {
|
||||||
|
publish(PID_CONFIG_TOPIC, 'std_msgs/String', {
|
||||||
|
data: JSON.stringify({
|
||||||
|
visual_yaw: params.visualYaw,
|
||||||
|
visual_pitch: params.visualPitch,
|
||||||
|
angle_yaw: params.angleYaw,
|
||||||
|
angle_pitch: params.anglePitch,
|
||||||
|
}),
|
||||||
|
})
|
||||||
|
console.log('PID 参数已发布:', params)
|
||||||
|
}
|
||||||
|
|
||||||
|
async function toggleLaser() {
|
||||||
|
try {
|
||||||
|
const action = laserOn.value ? 'off' : 'on'
|
||||||
|
await fetch(`${HTTP_API_BASE}/api/laser`, {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify({ action }),
|
||||||
|
})
|
||||||
|
laserOn.value = !laserOn.value
|
||||||
|
console.log(`激光已${action === 'on' ? '开启' : '关闭'}`)
|
||||||
|
} catch (e) {
|
||||||
|
console.error('激光切换失败:', e)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
.debug-mode { display: flex; flex-direction: column; gap: 20px; }
|
||||||
|
.header h2 { font-size: 18px; font-weight: 500; }
|
||||||
|
|
||||||
|
.mode-buttons { display: flex; gap: 10px; }
|
||||||
|
.btn-mode {
|
||||||
|
padding: 8px 20px;
|
||||||
|
border: 1px solid #0f3460; border-radius: 4px;
|
||||||
|
background: #16213e; color: #a0a0b0;
|
||||||
|
font-size: 14px; cursor: pointer;
|
||||||
|
transition: background 0.2s, color 0.2s;
|
||||||
|
}
|
||||||
|
.btn-mode:hover { background: #1a1a3e; color: #e0e0e0; }
|
||||||
|
.btn-mode.active { border-color: #e94560; color: #e94560; }
|
||||||
|
|
||||||
|
.pid-grid { display: grid; grid-template-columns: repeat(auto-fit, minmax(240px, 1fr)); gap: 16px; }
|
||||||
|
.pid-group {
|
||||||
|
background: #16213e; border: 1px solid #0f3460;
|
||||||
|
border-radius: 8px; padding: 16px;
|
||||||
|
display: flex; flex-direction: column; gap: 10px;
|
||||||
|
}
|
||||||
|
.pid-group h3 { font-size: 14px; font-weight: 500; color: #ccc; margin-bottom: 4px; }
|
||||||
|
.pid-group label {
|
||||||
|
font-size: 13px; color: #888;
|
||||||
|
display: flex; align-items: center; gap: 8px;
|
||||||
|
}
|
||||||
|
.pid-group input {
|
||||||
|
flex: 1; padding: 6px 10px;
|
||||||
|
border: 1px solid #0f3460; border-radius: 4px;
|
||||||
|
background: #1a1a2e; color: #eee;
|
||||||
|
font-size: 14px; outline: none;
|
||||||
|
width: 100px;
|
||||||
|
}
|
||||||
|
.pid-group input:focus { border-color: #e94560; }
|
||||||
|
|
||||||
|
.actions { display: flex; align-items: center; gap: 16px; }
|
||||||
|
.conn-status { font-size: 13px; color: #e94560; }
|
||||||
|
.conn-status.ok { color: #4ecca3; }
|
||||||
|
|
||||||
|
.btn-publish {
|
||||||
|
padding: 10px 28px;
|
||||||
|
border: none; border-radius: 4px;
|
||||||
|
background: #4ecca3; color: #1a1a2e;
|
||||||
|
font-size: 15px; font-weight: 600; cursor: pointer;
|
||||||
|
transition: background 0.2s;
|
||||||
|
}
|
||||||
|
.btn-publish:hover { background: #3bb08a; }
|
||||||
|
|
||||||
|
.btn-laser {
|
||||||
|
padding: 10px 28px;
|
||||||
|
border: 1px solid #0f3460; border-radius: 4px;
|
||||||
|
background: #16213e; color: #a0a0b0;
|
||||||
|
font-size: 15px; font-weight: 600; cursor: pointer;
|
||||||
|
transition: background 0.2s, color 0.2s;
|
||||||
|
}
|
||||||
|
.btn-laser:hover { background: #1a1a3e; color: #e0e0e0; }
|
||||||
|
</style>
|
||||||
@@ -0,0 +1,175 @@
|
|||||||
|
<template>
|
||||||
|
<div class="manual-mode">
|
||||||
|
<div class="header">
|
||||||
|
<h2>手动模式</h2>
|
||||||
|
<span class="conn-status" :class="{ ok: connected }">
|
||||||
|
rosbridge: {{ connected ? '已连接' : '未连接' }}
|
||||||
|
</span>
|
||||||
|
<button class="btn-switch" @click="switchToManual">切换到当前模式</button>
|
||||||
|
</div>
|
||||||
|
<div class="stream-container">
|
||||||
|
<img ref="streamImg" :src="streamUrl" alt="MJPEG Stream" class="stream" />
|
||||||
|
</div>
|
||||||
|
<div class="hint">
|
||||||
|
WASD 控制底盘 | ↑↓←→ 控制云台 | 支持多键同时按下
|
||||||
|
| J 激光: <span :class="laserOn ? 'laser-on' : 'laser-off'">{{ laserOn ? 'ON' : 'OFF' }}</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup>
|
||||||
|
import { ref, onMounted, onUnmounted } from 'vue'
|
||||||
|
import { onBeforeRouteLeave } from 'vue-router'
|
||||||
|
import {
|
||||||
|
MJPEG_MANUAL_URL, HTTP_API_BASE, ROSBRIDGE_URL,
|
||||||
|
CMD_WHEEL_TOPIC, CMD_GIMBAL_TOPIC,
|
||||||
|
MAX_LINEAR, MAX_ANGULAR, WHEEL_SEPARATION,
|
||||||
|
GIMBAL_PITCH_DEG_S, GIMBAL_YAW_DEG_S,
|
||||||
|
} from '../config.js'
|
||||||
|
import { useRosbridge } from '../composables/useRosbridge.js'
|
||||||
|
|
||||||
|
const streamUrl = MJPEG_MANUAL_URL
|
||||||
|
const streamImg = ref(null)
|
||||||
|
const { connected, connect, publish } = useRosbridge()
|
||||||
|
connect(ROSBRIDGE_URL)
|
||||||
|
|
||||||
|
const pressed = new Set()
|
||||||
|
const laserOn = ref(false)
|
||||||
|
let animId = null
|
||||||
|
let lastVel = { left: 0, right: 0, pitch: 0, yaw: 0 }
|
||||||
|
|
||||||
|
function onKeyDown(e) {
|
||||||
|
if (['KeyW','KeyA','KeyS','KeyD','ArrowUp','ArrowDown','ArrowLeft','ArrowRight'].includes(e.code)) {
|
||||||
|
e.preventDefault()
|
||||||
|
if (!pressed.has(e.code)) {
|
||||||
|
pressed.add(e.code)
|
||||||
|
if (!animId) startLoop()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (e.code === 'KeyJ' && !e.repeat) {
|
||||||
|
e.preventDefault()
|
||||||
|
toggleLaser()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function onKeyUp(e) {
|
||||||
|
pressed.delete(e.code)
|
||||||
|
}
|
||||||
|
|
||||||
|
function computeVelocities() {
|
||||||
|
const linear = (pressed.has('KeyW') ? MAX_LINEAR : 0) - (pressed.has('KeyS') ? MAX_LINEAR : 0)
|
||||||
|
const angular = (pressed.has('KeyA') ? MAX_ANGULAR : 0) - (pressed.has('KeyD') ? MAX_ANGULAR : 0)
|
||||||
|
const pitch = (pressed.has('ArrowDown') ? GIMBAL_PITCH_DEG_S : 0) - (pressed.has('ArrowUp') ? GIMBAL_PITCH_DEG_S : 0)
|
||||||
|
const yaw = (pressed.has('ArrowLeft') ? GIMBAL_YAW_DEG_S : 0) - (pressed.has('ArrowRight') ? GIMBAL_YAW_DEG_S : 0)
|
||||||
|
|
||||||
|
const halfSep = WHEEL_SEPARATION / 2
|
||||||
|
const left = linear - angular * halfSep
|
||||||
|
const right = linear + angular * halfSep
|
||||||
|
|
||||||
|
return { left, right, pitch, yaw }
|
||||||
|
}
|
||||||
|
|
||||||
|
function startLoop() {
|
||||||
|
function tick() {
|
||||||
|
const { left, right, pitch, yaw } = computeVelocities()
|
||||||
|
const changed = left !== lastVel.left || right !== lastVel.right || pitch !== lastVel.pitch || yaw !== lastVel.yaw
|
||||||
|
|
||||||
|
if (changed) {
|
||||||
|
publish(CMD_WHEEL_TOPIC, 'std_msgs/Float32MultiArray', {
|
||||||
|
data: [left, right],
|
||||||
|
layout: { dim: [], data_offset: 0 },
|
||||||
|
})
|
||||||
|
publish(CMD_GIMBAL_TOPIC, 'geometry_msgs/Twist', {
|
||||||
|
linear: { x: 0, y: 0, z: 0 },
|
||||||
|
angular: { x: 0, y: pitch, z: yaw },
|
||||||
|
})
|
||||||
|
lastVel = { left, right, pitch, yaw }
|
||||||
|
}
|
||||||
|
|
||||||
|
if (pressed.size === 0) {
|
||||||
|
animId = null
|
||||||
|
return
|
||||||
|
}
|
||||||
|
animId = requestAnimationFrame(tick)
|
||||||
|
}
|
||||||
|
animId = requestAnimationFrame(tick)
|
||||||
|
}
|
||||||
|
|
||||||
|
async function toggleLaser() {
|
||||||
|
try {
|
||||||
|
const action = laserOn.value ? 'off' : 'on'
|
||||||
|
await fetch(`${HTTP_API_BASE}/api/laser`, {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify({ action }),
|
||||||
|
})
|
||||||
|
laserOn.value = !laserOn.value
|
||||||
|
console.log(`激光已${action === 'on' ? '开启' : '关闭'}`)
|
||||||
|
} catch (e) {
|
||||||
|
console.error('激光切换失败:', e)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function switchToManual() {
|
||||||
|
try {
|
||||||
|
await fetch(`${HTTP_API_BASE}/api/mode/switch`, {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify({ mode: 'manual' }),
|
||||||
|
})
|
||||||
|
console.log('已切换到手动模式')
|
||||||
|
} catch (e) {
|
||||||
|
console.error('模式切换失败:', e)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
onMounted(() => {
|
||||||
|
window.addEventListener('keydown', onKeyDown)
|
||||||
|
window.addEventListener('keyup', onKeyUp)
|
||||||
|
})
|
||||||
|
|
||||||
|
onBeforeRouteLeave((_to, _from, next) => {
|
||||||
|
if (streamImg.value) streamImg.value.src = ''
|
||||||
|
next()
|
||||||
|
})
|
||||||
|
|
||||||
|
onUnmounted(() => {
|
||||||
|
window.removeEventListener('keydown', onKeyDown)
|
||||||
|
window.removeEventListener('keyup', onKeyUp)
|
||||||
|
if (animId) cancelAnimationFrame(animId)
|
||||||
|
})
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
.manual-mode { display: flex; flex-direction: column; gap: 16px; }
|
||||||
|
.header { display: flex; align-items: center; gap: 20px; }
|
||||||
|
.header h2 { font-size: 18px; font-weight: 500; }
|
||||||
|
.conn-status { font-size: 13px; color: #e94560; }
|
||||||
|
.conn-status.ok { color: #4ecca3; }
|
||||||
|
|
||||||
|
.btn-switch {
|
||||||
|
padding: 8px 20px;
|
||||||
|
border: none; border-radius: 4px;
|
||||||
|
background: #e94560; color: #fff;
|
||||||
|
font-size: 14px; cursor: pointer;
|
||||||
|
transition: background 0.2s;
|
||||||
|
margin-left: auto;
|
||||||
|
}
|
||||||
|
.btn-switch:hover { background: #c73a52; }
|
||||||
|
|
||||||
|
.stream-container {
|
||||||
|
background: #000;
|
||||||
|
border-radius: 8px;
|
||||||
|
overflow: hidden;
|
||||||
|
display: flex;
|
||||||
|
justify-content: center;
|
||||||
|
}
|
||||||
|
.stream { max-width: 100%; height: auto; }
|
||||||
|
|
||||||
|
.hint {
|
||||||
|
text-align: center; font-size: 13px; color: #666;
|
||||||
|
padding: 8px 0;
|
||||||
|
}
|
||||||
|
.laser-on { color: #4ecca3; font-weight: 600; }
|
||||||
|
.laser-off { color: #666; }
|
||||||
|
</style>
|
||||||
@@ -0,0 +1,6 @@
|
|||||||
|
import { defineConfig } from 'vite'
|
||||||
|
import vue from '@vitejs/plugin-vue'
|
||||||
|
|
||||||
|
export default defineConfig({
|
||||||
|
plugins: [vue()],
|
||||||
|
})
|
||||||
Reference in New Issue
Block a user