init
This commit is contained in:
+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>
|
||||
Reference in New Issue
Block a user