新增软件前端初版

pull/2/head
Surponess 3 weeks ago
parent d358997df8
commit a36b34837e

@ -0,0 +1,11 @@
{
"created_time": "2026-04-20T02:11:45Z",
"files": [
],
"folders": [
],
"id": "1542",
"modified_time": "2026-04-20T02:11:45Z",
"signature": "4031563627892149089",
"version": 3
}

@ -0,0 +1,8 @@
{
"permissions": {
"allow": [
"Bash(curl -sL \"https://unpkg.com/roslib@2.1.0/dist/\")",
"Bash(curl -sL \"https://unpkg.com/roslib@1.3.0/build/roslib.min.js\" -o \"d:/29578/Documents/Study/computer/S_E/软件体系结构与设计/软件前端/js/roslib.min.js\" -w \"%{http_code} %{size_download}\")"
]
}
}

@ -0,0 +1,383 @@
/* ===== 全局样式 ===== */
* {
margin: 0;
padding: 0;
box-sizing: border-box;
}
body {
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', 'PingFang SC', 'Microsoft YaHei', sans-serif;
background: #f0f2f5;
height: 100vh;
display: flex;
flex-direction: column;
overflow: hidden;
color: #333;
}
/* ===== 顶部状态栏 ===== */
.top-bar {
display: flex;
justify-content: space-between;
align-items: center;
height: 48px;
padding: 0 16px;
background: #1a1a2e;
color: #e0e0e0;
flex-shrink: 0;
z-index: 1000;
}
.top-bar-left {
display: flex;
align-items: center;
gap: 16px;
}
.logo {
font-size: 15px;
font-weight: 700;
color: #fff;
letter-spacing: 1px;
}
.status-item {
font-size: 13px;
color: #aaa;
}
.status-item strong {
color: #e0e0e0;
}
.top-bar-right {
display: flex;
align-items: center;
gap: 8px;
}
.top-bar-right input {
width: 220px;
padding: 5px 10px;
border: 1px solid #444;
border-radius: 4px;
background: #2a2a3e;
color: #e0e0e0;
font-size: 13px;
}
/* ===== 连接模式切换 ===== */
.conn-mode-group {
display: flex;
border-radius: 4px;
overflow: hidden;
border: 1px solid #444;
}
.mode-btn {
padding: 4px 12px;
background: #2a2a3e;
color: #aaa;
border: none;
cursor: pointer;
font-size: 12px;
font-weight: 600;
transition: all 0.2s;
}
.mode-btn:hover { color: #ddd; }
.mode-btn.active {
background: #007aff;
color: #fff;
}
.conn-mode-tag {
font-size: 11px;
padding: 2px 8px;
border-radius: 3px;
background: #2a2a3e;
color: #888;
white-space: nowrap;
}
/* ===== 标签 ===== */
.tag {
display: inline-block;
padding: 2px 10px;
border-radius: 10px;
font-size: 12px;
font-weight: 600;
}
.tag-success { background: #f6ffed; color: #52c41a; }
.tag-danger { background: #fff1f0; color: #ff4d4f; }
.tag-warning { background: #fffbe6; color: #faad14; }
.tag-info { background: #e6f7ff; color: #007aff; }
/* ===== 按钮 ===== */
.btn {
padding: 7px 14px;
border: none;
border-radius: 6px;
cursor: pointer;
font-size: 13px;
font-weight: 600;
transition: opacity 0.2s;
}
.btn:disabled {
opacity: 0.4;
cursor: not-allowed;
}
.btn:not(:disabled):hover { opacity: 0.85; }
.btn-primary { background: #007aff; color: #fff; }
.btn-success { background: #52c41a; color: #fff; }
.btn-warning { background: #faad14; color: #fff; }
.btn-danger { background: #ff4d4f; color: #fff; }
.btn-secondary { background: #e0e0e0; color: #333; }
.btn.wide { width: 100%; }
.btn-link {
background: none;
border: none;
color: #999;
cursor: pointer;
font-size: 12px;
}
.btn-link:hover { color: #007aff; }
.btn-group {
display: flex;
gap: 8px;
margin-bottom: 8px;
}
.btn-group .btn { flex: 1; }
/* ===== 主内容区 ===== */
.main-content {
display: flex;
flex: 1;
min-height: 0;
}
/* ===== 地图 ===== */
.map-container {
flex: 1;
min-width: 0;
z-index: 1;
}
/* ===== 右侧控制面板 ===== */
.control-panel {
width: 300px;
flex-shrink: 0;
background: #fff;
border-left: 1px solid #e0e0e0;
overflow-y: auto;
display: flex;
flex-direction: column;
}
.panel-section {
padding: 12px;
border-bottom: 1px solid #f0f0f0;
}
.panel-header {
font-size: 14px;
font-weight: 700;
color: #333;
margin-bottom: 10px;
display: flex;
justify-content: space-between;
align-items: center;
}
.panel-header span:last-child {
font-weight: 400;
font-size: 12px;
color: #999;
}
/* ===== 航点列表 ===== */
.waypoint-list {
max-height: 220px;
overflow-y: auto;
margin-bottom: 8px;
}
.empty-hint {
text-align: center;
color: #bbb;
font-size: 13px;
padding: 20px 0;
}
.wp-item {
display: flex;
align-items: center;
padding: 6px 8px;
border-radius: 6px;
margin-bottom: 4px;
background: #f8f9fa;
cursor: pointer;
font-size: 13px;
transition: background 0.15s;
}
.wp-item:hover { background: #e6f7ff; }
.wp-item.active { background: #e6f7ff; border: 1px solid #91d5ff; }
.wp-index {
width: 22px;
height: 22px;
border-radius: 50%;
background: #007aff;
color: #fff;
display: flex;
align-items: center;
justify-content: center;
font-size: 11px;
font-weight: 700;
margin-right: 8px;
flex-shrink: 0;
}
.wp-info {
flex: 1;
min-width: 0;
}
.wp-coord {
font-size: 11px;
color: #666;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.wp-alt {
font-size: 11px;
color: #999;
}
.wp-delete {
width: 20px;
height: 20px;
border: none;
background: none;
color: #ccc;
cursor: pointer;
font-size: 16px;
line-height: 1;
flex-shrink: 0;
}
.wp-delete:hover { color: #ff4d4f; }
.wp-params {
display: flex;
gap: 10px;
font-size: 12px;
color: #666;
}
.wp-params input {
width: 60px;
padding: 3px 6px;
border: 1px solid #ddd;
border-radius: 4px;
font-size: 12px;
text-align: center;
}
/* ===== 数据面板 ===== */
.data-grid {
display: grid;
grid-template-columns: 1fr 1fr;
gap: 8px;
}
.data-item {
background: #f8f9fa;
border-radius: 6px;
padding: 8px;
text-align: center;
}
.data-label {
display: block;
font-size: 11px;
color: #999;
margin-bottom: 2px;
}
.data-value {
font-size: 13px;
font-weight: 600;
color: #333;
}
/* ===== 底部日志栏 ===== */
.log-bar {
height: 120px;
flex-shrink: 0;
background: #1e1e1e;
display: flex;
flex-direction: column;
}
.log-header {
display: flex;
justify-content: space-between;
align-items: center;
padding: 4px 12px;
font-size: 12px;
color: #888;
background: #2a2a2a;
border-bottom: 1px solid #333;
}
.log-content {
flex: 1;
overflow-y: auto;
padding: 4px 12px;
font-family: 'Consolas', 'Monaco', monospace;
font-size: 12px;
line-height: 1.6;
}
.log-entry {
white-space: nowrap;
}
.log-entry .time { color: #666; margin-right: 8px; }
.log-entry.success { color: #52c41a; }
.log-entry.error { color: #ff4d4f; }
.log-entry.warning { color: #faad14; }
.log-entry.info { color: #aaa; }
/* ===== Leaflet 自定义 ===== */
.drone-icon {
width: 24px;
height: 24px;
position: relative;
}
.drone-icon-inner {
width: 0;
height: 0;
border-left: 10px solid transparent;
border-right: 10px solid transparent;
border-bottom: 20px solid #007aff;
position: absolute;
top: 2px;
left: 2px;
transform-origin: center 60%;
}

@ -0,0 +1,118 @@
<!DOCTYPE html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>P600 无人机定点巡航控制系统</title>
<link rel="stylesheet" href="https://unpkg.com/leaflet@1.9.4/dist/leaflet.css" />
<link rel="stylesheet" href="/css/style.css" />
</head>
<body>
<!-- 顶部状态栏 -->
<header class="top-bar">
<div class="top-bar-left">
<span class="logo">P600 定点巡航控制系统</span>
<span id="conn-status" class="tag tag-danger">未连接</span>
<span class="status-item">模式: <strong id="flight-mode">--</strong></span>
<span class="status-item">电量: <strong id="battery">--%</strong></span>
<span class="status-item">GPS: <strong id="gps-status">--</strong></span>
<span class="status-item">速度: <strong id="speed">-- m/s</strong></span>
<span class="status-item">高度: <strong id="altitude">-- m</strong></span>
</div>
<div class="top-bar-right">
<div class="conn-mode-group">
<button id="btn-mode-sim" class="mode-btn active" title="Prometheus SITL 仿真 (rosbridge WebSocket)">仿真模式</button>
<button id="btn-mode-real" class="mode-btn" title="P600 实机 WiFi 数传 (rosbridge WebSocket)">实机模式</button>
</div>
<input type="text" id="drone-url" value="ws://localhost:9090" placeholder="rosbridge 地址" />
<button id="btn-connect" class="btn btn-primary">连接无人机</button>
<span id="conn-mode-tag" class="conn-mode-tag">rosbridge</span>
</div>
</header>
<!-- 主内容区 -->
<div class="main-content">
<!-- 地图区域 -->
<div id="map" class="map-container"></div>
<!-- 右侧控制面板 -->
<aside class="control-panel">
<!-- 航点列表 -->
<div class="panel-section">
<div class="panel-header">
<span>航点列表</span>
<span id="wp-count">0 个航点</span>
</div>
<div id="waypoint-list" class="waypoint-list">
<div class="empty-hint">在地图上点击以添加航点</div>
</div>
<div class="wp-params">
<label>默认高度: <input type="number" id="default-alt" value="15" min="1" max="100" /></label>
<label>默认悬停: <input type="number" id="default-hold" value="0" min="0" max="60" /></label>
</div>
</div>
<!-- 任务操作 -->
<div class="panel-section">
<div class="panel-header">任务操作</div>
<div class="btn-group">
<button id="btn-upload" class="btn btn-primary wide" disabled>上传任务</button>
</div>
<div class="btn-group">
<button id="btn-start" class="btn btn-success wide" disabled>开始任务</button>
</div>
<div class="btn-group">
<button id="btn-pause" class="btn btn-warning" disabled>暂停</button>
<button id="btn-resume" class="btn btn-success" disabled>继续</button>
</div>
<div class="btn-group">
<button id="btn-rth" class="btn btn-danger" disabled>返航</button>
<button id="btn-land" class="btn btn-danger" disabled>降落</button>
</div>
<div class="btn-group">
<button id="btn-clear" class="btn btn-secondary wide">清除所有航点</button>
</div>
</div>
<!-- 实时数据 -->
<div class="panel-section">
<div class="panel-header">实时数据</div>
<div class="data-grid">
<div class="data-item">
<span class="data-label">纬度</span>
<span class="data-value" id="data-lat">--</span>
</div>
<div class="data-item">
<span class="data-label">经度</span>
<span class="data-value" id="data-lng">--</span>
</div>
<div class="data-item">
<span class="data-label">航向</span>
<span class="data-value" id="data-heading">--</span>
</div>
<div class="data-item">
<span class="data-label">当前航点</span>
<span class="data-value" id="data-wp">--</span>
</div>
</div>
</div>
</aside>
</div>
<!-- 底部日志栏 -->
<footer class="log-bar">
<div class="log-header">
<span>操作日志</span>
<button id="btn-clear-log" class="btn-link">清空</button>
</div>
<div id="log-content" class="log-content"></div>
</footer>
<!-- JS 依赖 -->
<script src="https://unpkg.com/leaflet@1.9.4/dist/leaflet.js"></script>
<script src="/js/roslib.min.js"></script>
<script src="/js/map.js"></script>
<script src="/js/websocket.js"></script>
<script src="/js/ui.js"></script>
</body>
</html>

@ -0,0 +1,202 @@
/**
* 地图模块 - 基于 Leaflet.js
* 负责: 地图初始化航点管理航线绘制无人机位置显示
*/
const MapModule = (() => {
let map = null;
let waypoints = []; // 航点数据数组
let markers = []; // 航点 Leaflet Marker 对象
let polyline = null; // 航线折线
let droneMarker = null; // 无人机位置标记
let trailLine = null; // 飞行轨迹
let trailCoords = []; // 轨迹坐标历史
const DEFAULT_LAT = 30.0;
const DEFAULT_LNG = 120.0;
const DEFAULT_ZOOM = 16;
function init() {
map = L.map('map', {
center: [DEFAULT_LAT, DEFAULT_LNG],
zoom: DEFAULT_ZOOM,
zoomControl: true
});
// OpenStreetMap 底图(免费,无需 Key
L.tileLayer('https://tile.openstreetmap.org/{z}/{x}/{y}.png', {
attribution: '&copy; OpenStreetMap',
maxZoom: 19
}).addTo(map);
// 点击地图添加航点
map.on('click', onMapClick);
// 初始化航线(空折线)
polyline = L.polyline([], {
color: '#007aff',
weight: 2,
opacity: 0.7,
dashArray: '8, 6'
}).addTo(map);
// 初始化飞行轨迹
trailLine = L.polyline([], {
color: '#ff4d4f',
weight: 2,
opacity: 0.6
}).addTo(map);
// 创建无人机图标
const droneIcon = L.divIcon({
className: 'drone-icon',
html: '<div class="drone-icon-inner" id="drone-arrow"></div>',
iconSize: [24, 24],
iconAnchor: [12, 12]
});
droneMarker = L.marker([DEFAULT_LAT, DEFAULT_LNG], {
icon: droneIcon,
visible: false
}).addTo(map);
}
function onMapClick(e) {
addWaypoint(e.latlng.lat, e.latlng.lng);
}
function addWaypoint(lat, lng) {
const defaultAlt = parseInt(document.getElementById('default-alt').value) || 15;
const defaultHold = parseInt(document.getElementById('default-hold').value) || 0;
const index = waypoints.length + 1;
const wp = {
id: Date.now(),
lat: parseFloat(lat.toFixed(6)),
lng: parseFloat(lng.toFixed(6)),
alt: defaultAlt,
hold_time: defaultHold
};
waypoints.push(wp);
// 添加地图标记
const marker = L.marker([wp.lat, wp.lng], {
draggable: true
}).addTo(map);
marker.bindPopup(`<b>航点 ${index}</b><br>高度: ${wp.alt}m<br>悬停: ${wp.hold_time}s`);
// 拖拽更新航点坐标
marker.on('dragend', function () {
const pos = marker.getLatLng();
const wpIdx = markers.indexOf(marker);
if (wpIdx >= 0) {
waypoints[wpIdx].lat = parseFloat(pos.lat.toFixed(6));
waypoints[wpIdx].lng = parseFloat(pos.lng.toFixed(6));
marker.setPopupContent(`<b>航点 ${wpIdx + 1}</b><br>高度: ${waypoints[wpIdx].alt}m`);
updatePolyline();
UIModule.updateWaypointList();
}
});
markers.push(marker);
updatePolyline();
UIModule.updateWaypointList();
UIModule.updateButtons();
}
function removeWaypoint(index) {
if (index < 0 || index >= waypoints.length) return;
waypoints.splice(index, 1);
const marker = markers.splice(index, 1)[0];
map.removeLayer(marker);
// 重新编号弹窗
markers.forEach((m, i) => {
m.setPopupContent(`<b>航点 ${i + 1}</b><br>高度: ${waypoints[i].alt}m`);
});
updatePolyline();
UIModule.updateWaypointList();
UIModule.updateButtons();
}
function clearWaypoints() {
markers.forEach(m => map.removeLayer(m));
waypoints = [];
markers = [];
updatePolyline();
UIModule.updateWaypointList();
UIModule.updateButtons();
}
function updatePolyline() {
const coords = waypoints.map(wp => [wp.lat, wp.lng]);
polyline.setLatLngs(coords);
}
function updateDronePosition(lat, lng, heading) {
if (!droneMarker) return;
droneMarker.setLatLng([lat, lng]);
if (!droneMarker.visible) {
droneMarker.visible = true;
}
// 旋转无人机图标(航向)
const arrow = document.getElementById('drone-arrow');
if (arrow) {
arrow.style.transform = `rotate(${heading}deg)`;
}
// 记录轨迹
trailCoords.push([lat, lng]);
if (trailCoords.length > 500) trailCoords.shift();
trailLine.setLatLngs(trailCoords);
}
function centerOnDrone(lat, lng) {
map.setView([lat, lng], map.getZoom());
}
function panTo(lat, lng) {
map.panTo([lat, lng]);
}
function getWaypoints() {
return waypoints.map(wp => ({
lat: wp.lat,
lng: wp.lng,
alt: wp.alt,
hold_time: wp.hold_time
}));
}
function getWaypointCount() {
return waypoints.length;
}
function updateWaypointAlt(index, alt) {
if (index >= 0 && index < waypoints.length) {
waypoints[index].alt = alt;
markers[index].setPopupContent(
`<b>航点 ${index + 1}</b><br>高度: ${alt}m<br>悬停: ${waypoints[index].hold_time}s`
);
}
}
return {
init,
addWaypoint,
removeWaypoint,
clearWaypoints,
updateDronePosition,
centerOnDrone,
panTo,
getWaypoints,
getWaypointCount,
updateWaypointAlt,
DEFAULT_LAT,
DEFAULT_LNG
};
})();

File diff suppressed because one or more lines are too long

@ -0,0 +1,335 @@
/**
* UI 交互模块
* 负责: 按钮事件绑定状态管理航点列表渲染日志输出
*/
const UIModule = (() => {
// 应用状态
let droneConnected = false;
let missionUploaded = false;
let missionRunning = false;
let missionPaused = false;
let connectionMode = 'sim'; // 'sim' 仿真 | 'real' 实机
// 连接预设 (rosbridge WebSocket 地址)
const PRESETS = {
sim: { url: 'ws://localhost:9090', label: '仿真 rosbridge', desc: 'Prometheus SITL 仿真 (本地 rosbridge 端口 9090)' },
real: { url: 'ws://192.168.1.31:9090', label: '实机 rosbridge', desc: 'P600 实机 WiFi 数传 (rosbridge 192.168.1.31:9090)' }
};
function init() {
// 初始化地图
MapModule.init();
// 初始化 WebSocket
WsModule.init();
// 绑定按钮事件
document.getElementById('btn-connect').addEventListener('click', onConnect);
document.getElementById('btn-upload').addEventListener('click', onUpload);
document.getElementById('btn-start').addEventListener('click', onStart);
document.getElementById('btn-pause').addEventListener('click', onPause);
document.getElementById('btn-resume').addEventListener('click', onResume);
document.getElementById('btn-rth').addEventListener('click', onReturnHome);
document.getElementById('btn-land').addEventListener('click', onLand);
document.getElementById('btn-clear').addEventListener('click', onClear);
document.getElementById('btn-clear-log').addEventListener('click', onClearLog);
// 绑定连接模式切换
document.getElementById('btn-mode-sim').addEventListener('click', () => switchMode('sim'));
document.getElementById('btn-mode-real').addEventListener('click', () => switchMode('real'));
// 连接地址输入框变化时更新标签
document.getElementById('drone-url').addEventListener('input', onUrlInput);
updateButtons();
addLog('info', '系统就绪,当前模式: 仿真 (rosbridge)');
addLog('info', '提示: 浏览器通过 roslibjs 直接连接 rosbridge_server无需 MAVSDK 后端');
}
// ---------- 连接模式切换 ----------
function switchMode(mode) {
if (droneConnected) {
addLog('warning', '请先断开当前连接再切换模式');
return;
}
connectionMode = mode;
const preset = PRESETS[mode];
// 更新按钮状态
document.getElementById('btn-mode-sim').classList.toggle('active', mode === 'sim');
document.getElementById('btn-mode-real').classList.toggle('active', mode === 'real');
// 自动填入预设地址
document.getElementById('drone-url').value = preset.url;
document.getElementById('conn-mode-tag').textContent = preset.label;
addLog('info', `已切换到${preset.label}模式: ${preset.desc}`);
if (mode === 'sim') {
addLog('info', '仿真模式说明: 先启动 Prometheus SITL (roslaunch prometheus_gazebo sitl.launch),再启动 rosbridge (roslaunch rosbridge_server rosbridge_websocket.launch)');
} else {
addLog('info', '实机模式说明: 请确保电脑已连接 P600 的 WiFi 数传rosbridge 需在机载电脑上运行');
}
}
function onUrlInput() {
const url = document.getElementById('drone-url').value.trim();
const tag = document.getElementById('conn-mode-tag');
if (url.startsWith('ws://')) {
tag.textContent = 'rosbridge';
} else if (url.startsWith('wss://')) {
tag.textContent = 'rosbridge (SSL)';
} else {
tag.textContent = '未知';
}
}
// ---------- 按钮事件处理 ----------
function onConnect() {
const url = document.getElementById('drone-url').value.trim();
if (!url) {
addLog('error', '请输入 rosbridge 连接地址');
return;
}
if (!url.startsWith('ws://') && !url.startsWith('wss://')) {
addLog('error', '地址格式错误,应以 ws:// 开头 (例如 ws://localhost:9090)');
return;
}
WsModule.connectDrone(url);
}
function onUpload() {
const waypoints = MapModule.getWaypoints();
if (waypoints.length === 0) {
addLog('error', '请先在地图上添加航点');
return;
}
WsModule.uploadMission(waypoints);
}
function onStart() {
WsModule.startMission();
}
function onPause() {
WsModule.pauseMission();
}
function onResume() {
WsModule.resumeMission();
}
function onReturnHome() {
WsModule.returnHome();
}
function onLand() {
WsModule.land();
}
function onClear() {
MapModule.clearWaypoints();
missionUploaded = false;
updateButtons();
addLog('info', '已清除所有航点');
}
function onClearLog() {
document.getElementById('log-content').innerHTML = '';
}
// ---------- 状态回调(由 WsModule 触发) ----------
function onTelemetry(data) {
if (data.type === 'telemetry') {
// 更新顶栏数据
document.getElementById('battery').textContent = data.battery.toFixed(0) + '%';
document.getElementById('speed').textContent = data.speed.toFixed(1) + ' m/s';
document.getElementById('altitude').textContent = data.alt.toFixed(1) + ' m';
document.getElementById('gps-status').textContent = data.gps_status;
document.getElementById('flight-mode').textContent = data.flight_mode;
// 更新右侧数据面板
document.getElementById('data-lat').textContent = data.lat.toFixed(6);
document.getElementById('data-lng').textContent = data.lng.toFixed(6);
document.getElementById('data-heading').textContent = data.heading.toFixed(0) + '°';
document.getElementById('data-wp').textContent = data.current_wp;
// 更新无人机位置 — 只要坐标有效就更新
if (droneConnected && (data.lat !== 0 || data.alt > 0.1)) {
MapModule.updateDronePosition(data.lat, data.lng, data.heading);
}
}
if (data.type === 'mission_progress') {
document.getElementById('data-wp').textContent =
data.current + ' / ' + data.total;
}
}
function onStatus(data) {
addLog(data.type, data.message);
// 根据状态消息更新应用状态
const msg = data.message;
if (msg.includes('连接成功') || msg.includes('已连接')) {
droneConnected = true;
document.getElementById('conn-status').className = 'tag tag-success';
document.getElementById('conn-status').textContent = '已连接';
document.getElementById('btn-connect').textContent = '已连接';
document.getElementById('btn-connect').disabled = true;
updateButtons();
}
if (msg.includes('上传成功')) {
missionUploaded = true;
updateButtons();
}
if (msg.includes('任务已开始')) {
missionRunning = true;
missionPaused = false;
updateButtons();
}
if (msg.includes('已暂停')) {
missionRunning = false;
missionPaused = true;
updateButtons();
}
if (msg.includes('已继续')) {
missionRunning = true;
missionPaused = false;
updateButtons();
}
if (msg.includes('返航') || msg.includes('降落') || msg.includes('失败')) {
if (msg.includes('返航') || msg.includes('降落')) {
missionRunning = false;
missionPaused = false;
updateButtons();
}
}
if (msg.includes('任务完成')) {
missionRunning = false;
missionPaused = false;
missionUploaded = false;
updateButtons();
}
}
function onDroneDisconnected() {
droneConnected = false;
missionUploaded = false;
missionRunning = false;
missionPaused = false;
document.getElementById('conn-status').className = 'tag tag-danger';
document.getElementById('conn-status').textContent = '未连接';
document.getElementById('btn-connect').textContent = '连接无人机';
document.getElementById('btn-connect').disabled = false;
updateButtons();
addLog('error', '无人机连接断开');
}
// ---------- 按钮状态管理 ----------
function updateButtons() {
const wpCount = MapModule.getWaypointCount();
setBtn('btn-upload', droneConnected && wpCount > 0 && !missionRunning && !missionPaused);
setBtn('btn-start', droneConnected && missionUploaded && !missionRunning && !missionPaused);
setBtn('btn-pause', droneConnected && missionRunning);
setBtn('btn-resume', droneConnected && missionPaused);
setBtn('btn-rth', droneConnected);
setBtn('btn-land', droneConnected);
}
function setBtn(id, enabled) {
document.getElementById(id).disabled = !enabled;
}
// ---------- 航点列表渲染 ----------
function updateWaypointList() {
const container = document.getElementById('waypoint-list');
const waypoints = MapModule.getWaypoints();
document.getElementById('wp-count').textContent = waypoints.length + ' 个航点';
if (waypoints.length === 0) {
container.innerHTML = '<div class="empty-hint">在地图上点击以添加航点</div>';
return;
}
container.innerHTML = waypoints.map((wp, i) => `
<div class="wp-item" data-index="${i}">
<span class="wp-index">${i + 1}</span>
<div class="wp-info">
<div class="wp-coord">${wp.lat}, ${wp.lng}</div>
<div class="wp-alt">${wp.alt}m | 悬停 ${wp.hold_time}s</div>
</div>
<button class="wp-delete" data-index="${i}" title="删除">&times;</button>
</div>
`).join('');
// 绑定点击定位事件
container.querySelectorAll('.wp-item').forEach(item => {
item.addEventListener('click', (e) => {
if (e.target.classList.contains('wp-delete')) return;
const idx = parseInt(item.dataset.index);
const wps = MapModule.getWaypoints();
if (wps[idx]) {
MapModule.panTo(wps[idx].lat, wps[idx].lng);
}
});
});
// 绑定删除事件
container.querySelectorAll('.wp-delete').forEach(btn => {
btn.addEventListener('click', () => {
MapModule.removeWaypoint(parseInt(btn.dataset.index));
});
});
}
// ---------- 日志输出 ----------
function addLog(type, message) {
const container = document.getElementById('log-content');
const now = new Date();
const time = now.toTimeString().split(' ')[0];
const entry = document.createElement('div');
entry.className = 'log-entry ' + type;
entry.innerHTML = `<span class="time">[${time}]</span>${message}`;
container.appendChild(entry);
// 自动滚动到底部
container.scrollTop = container.scrollHeight;
// 限制日志条数
while (container.children.length > 200) {
container.removeChild(container.firstChild);
}
}
return {
init,
onTelemetry,
onStatus,
onDroneDisconnected,
updateWaypointList,
updateButtons
};
})();
// 页面加载完成后初始化
document.addEventListener('DOMContentLoaded', () => {
UIModule.init();
});

@ -0,0 +1,683 @@
/**
* MAVROS 通信模块 - 基于 roslibjs
* 直接通过 MAVROS 话题和服务控制无人机
*
* 架构: 浏览器 roslibjs rosbridge_server ROS话题 MAVROS MAVLink PX4
*
* 使用的话题:
* 订阅: /uav1/mavros/state (mavros_msgs/State)
* 订阅: /uav1/mavros/local_position/pose (geometry_msgs/PoseStamped)
* 订阅: /uav1/mavros/global_position/raw/fix (sensor_msgs/NavSatFix)
* 订阅: /uav1/mavros/battery (sensor_msgs/BatteryState)
* 发布: /uav1/mavros/setpoint_position/local (geometry_msgs/PoseStamped)
* 服务: /uav1/mavros/cmd/arming (mavros_msgs/CommandBool)
* 服务: /uav1/mavros/set_mode (mavros_msgs/SetMode)
*/
const WsModule = (() => {
let ros = null;
let rosConnected = false;
let droneConnected = false;
let fcuReady = false; // true when MAVROS reports valid mode (FCU connection established)
// ========== 配置: UAV 命名空间 ==========
// 仿真环境默认: '/uav1' 实机可能: '' 或 '/uav1'
// 如果话题不对,改这里
let uavNS = '/uav1';
function topicName(name) { return uavNS + name; }
function serviceName(name) { return uavNS + name; }
// ROS handles
let stateSub = null;
let localPosSub = null;
let gpsSub = null;
let batterySub = null;
let setpointPub = null;
// Telemetry state
let telemetry = {
lat: 0, lng: 0, alt: 0,
speed: 0, battery: 100, heading: 0,
flightMode: 'UNKNOWN', gpsStatus: 'No Fix',
armed: false,
localPos: { x: 0, y: 0, z: 0 } // ENU: x=East, y=North, z=Up
};
// Origin for lat/lng <-> ENU conversion
let origin = { lat: null, lng: null };
let originSet = false;
let originFromGps = false; // true if origin was set from real GPS
// Navigation state
let rawWaypoints = []; // Original lat/lng waypoints
let navWaypoints = []; // Converted ENU waypoints
let currentWPIndex = 0;
let navActive = false;
let navPaused = false;
let publishTimer = null;
let wpCheckTimer = null;
let telemetryTimer = null; // Throttled UI update timer
const WP_REACH_DIST = 2.0; // meters
const PUBLISH_INTERVAL = 100; // ms (10 Hz, OFFBOARD needs >= 2Hz)
// ---- Coordinate conversion ----
function latLngToENU(lat, lng, alt) {
if (!originSet) return { x: 0, y: 0, z: alt };
const latRad = origin.lat * Math.PI / 180;
const x = (lng - origin.lng) * Math.cos(latRad) * 111319.488; // East
const y = (lat - origin.lat) * 111319.488; // North
return { x, y, z: alt }; // Up
}
function enuToLatLng(x, y) {
if (!originSet) return { lat: 0, lng: 0 };
const latRad = origin.lat * Math.PI / 180;
const lat = origin.lat + y / 111319.488;
const lng = origin.lng + x / (111319.488 * Math.cos(latRad));
return { lat, lng };
}
// Quaternion to yaw (degrees, ENU: 0=East, CCW+)
function quatToYawDeg(qx, qy, qz, qw) {
const siny = 2 * (qw * qz + qx * qy);
const cosy = 1 - 2 * (qy * qy + qz * qz);
let yaw = Math.atan2(siny, cosy) * 180 / Math.PI;
// Convert to compass heading: 0=North, CW+
return (90 - yaw + 360) % 360;
}
// ---- Init & Connection ----
function init() {}
function isConnected() { return rosConnected; }
function connectDrone(url) {
try {
if (typeof ROSLIB === 'undefined') {
UIModule.onStatus({ type: 'error', message: 'roslibjs 库未加载' });
return;
}
if (ros) {
try { ros.close(); } catch (e) { /* ignore */ }
}
UIModule.onStatus({ type: 'info', message: `正在连接 rosbridge: ${url} ...` });
ros = new ROSLIB.Ros({ url: url });
ros.on('connection', () => {
rosConnected = true;
UIModule.onStatus({ type: 'info', message: 'rosbridge 已连接,正在检测 MAVROS 话题...' });
setupSubscriptions();
});
ros.on('error', (error) => {
UIModule.onStatus({ type: 'error', message: `rosbridge 连接错误: ${error || '未知'}` });
});
ros.on('close', () => {
const wasConnected = rosConnected;
rosConnected = false;
droneConnected = false;
stopAll();
if (wasConnected) {
UIModule.onDroneDisconnected();
}
});
} catch (e) {
UIModule.onStatus({ type: 'error', message: `连接失败: ${e.message || e}` });
}
}
// ---- Topic subscriptions ----
function setupSubscriptions() {
// Try to detect which namespace has MAVROS
trySubscribesWithFallback();
}
// Set a default origin for coordinate conversion (no GPS needed)
function setDefaultOrigin() {
if (originSet) return;
// Use map default center as origin - simulation doesn't need real GPS
origin.lat = MapModule.DEFAULT_LAT || 30.0;
origin.lng = MapModule.DEFAULT_LNG || 120.0;
originSet = true;
originFromGps = false;
UIModule.onStatus({ type: 'info', message: `仿真模式: 使用默认坐标原点 (${origin.lat}, ${origin.lng})无需GPS` });
}
function trySubscribesWithFallback() {
let gotState = false;
const tryNS = uavNS;
// Subscribe to MAVROS state
stateSub = new ROSLIB.Topic({
ros: ros,
name: topicName('/mavros/state'),
messageType: 'mavros_msgs/State'
});
const stateTimeout = setTimeout(() => {
if (!gotState) {
// Try without namespace
if (tryNS !== '') {
uavNS = '';
UIModule.onStatus({ type: 'warning', message: `未收到 ${tryNS}/mavros/state尝试无命名空间...` });
trySubscribesWithFallback();
} else {
UIModule.onStatus({ type: 'error', message: '未检测到 MAVROS 话题,请确认仿真/实机已启动' });
}
}
}, 5000);
stateSub.subscribe((msg) => {
clearTimeout(stateTimeout);
gotState = true;
telemetry.armed = msg.armed || false;
telemetry.flightMode = msg.mode || 'UNKNOWN';
// Check if FCU is truly connected (has valid mode)
const modeValid = msg.connected && msg.mode && msg.mode.length > 0;
if (!droneConnected) {
droneConnected = true;
if (modeValid) {
fcuReady = true;
UIModule.onStatus({ type: 'success', message: `无人机已连接 (模式: ${msg.mode}, ${msg.armed ? '已解锁' : '未解锁'})` });
} else {
UIModule.onStatus({ type: 'warning', message: `MAVROS 已连接,但飞控未就绪 (mode=空)。等待 PX4 初始化...` });
}
setDefaultOrigin();
}
// Detect when FCU becomes ready
if (!fcuReady && modeValid) {
fcuReady = true;
UIModule.onStatus({ type: 'success', message: `飞控已就绪! 当前模式: ${msg.mode}, 状态: ${msg.armed ? '已解锁' : '未解锁'}` });
}
});
// Local position (ENU: x=East, y=North, z=Up)
localPosSub = new ROSLIB.Topic({
ros: ros,
name: topicName('/mavros/local_position/pose'),
messageType: 'geometry_msgs/PoseStamped'
});
localPosSub.subscribe((msg) => {
if (msg.pose && msg.pose.position) {
telemetry.localPos = {
x: msg.pose.position.x, // East
y: msg.pose.position.y, // North
z: msg.pose.position.z // Up
};
}
if (msg.pose && msg.pose.orientation) {
const o = msg.pose.orientation;
telemetry.heading = quatToYawDeg(o.x, o.y, o.z, o.w);
}
});
// GPS (for lat/lng display and origin)
gpsSub = new ROSLIB.Topic({
ros: ros,
name: topicName('/mavros/global_position/raw/fix'),
messageType: 'sensor_msgs/NavSatFix'
});
gpsSub.subscribe((msg) => {
telemetry.lat = msg.latitude || 0;
telemetry.lng = msg.longitude || 0;
if (msg.status) {
telemetry.gpsStatus = msg.status.status >= 0 ? '3D Fix' : 'No Fix';
}
// Update origin from real GPS (overrides default origin)
if (msg.latitude !== 0 && Math.abs(msg.latitude) > 1) {
if (!originFromGps) {
origin.lat = msg.latitude;
origin.lng = msg.longitude;
originSet = true;
originFromGps = true;
UIModule.onStatus({ type: 'info', message: `GPS 坐标已更新: ${origin.lat.toFixed(6)}, ${origin.lng.toFixed(6)}` });
MapModule.centerOnDrone(msg.latitude, msg.longitude);
}
}
});
// Battery
batterySub = new ROSLIB.Topic({
ros: ros,
name: topicName('/mavros/battery'),
messageType: 'sensor_msgs/BatteryState'
});
batterySub.subscribe((msg) => {
telemetry.battery = (msg.percentage != null) ? msg.percentage * 100 : 100;
});
// Setpoint publisher — PoseStamped is the standard MAVROS position control interface
setpointPub = new ROSLIB.Topic({
ros: ros,
name: topicName('/mavros/setpoint_position/local'),
messageType: 'geometry_msgs/PoseStamped'
});
// Start throttled telemetry loop (5Hz, decoupled from subscription rate)
startTelemetryLoop();
}
function pushTelemetry() {
let displayLat = telemetry.lat;
let displayLng = telemetry.lng;
if ((!displayLat || displayLat === 0) && originSet) {
const ll = enuToLatLng(telemetry.localPos.x, telemetry.localPos.y);
displayLat = ll.lat;
displayLng = ll.lng;
}
UIModule.onTelemetry({
type: 'telemetry',
lat: displayLat,
lng: displayLng,
alt: telemetry.localPos.z,
speed: telemetry.speed,
battery: telemetry.battery,
heading: telemetry.heading,
flight_mode: telemetry.flightMode,
gps_status: telemetry.gpsStatus,
current_wp: (currentWPIndex + 1) + '/' + navWaypoints.length
});
}
function startTelemetryLoop() {
if (telemetryTimer) clearInterval(telemetryTimer);
pushTelemetry();
telemetryTimer = setInterval(pushTelemetry, 200); // 5Hz UI update
}
function stopTelemetryLoop() {
if (telemetryTimer) { clearInterval(telemetryTimer); telemetryTimer = null; }
}
// ---- Setpoint publishing ----
let spSeq = 0;
function publishSetpoint(east, north, up) {
if (!setpointPub || !rosConnected) return;
const msg = new ROSLIB.Message({
header: {
seq: spSeq++,
stamp: { secs: Math.floor(Date.now() / 1000), nsecs: 0 },
frame_id: 'map'
},
pose: {
position: { x: east, y: north, z: up },
orientation: { x: 0, y: 0, z: 0, w: 1 }
}
});
setpointPub.publish(msg);
}
function startPublishLoop(east, north, up) {
stopPublishing();
publishSetpoint(east, north, up);
publishTimer = setInterval(() => {
publishSetpoint(east, north, up);
}, PUBLISH_INTERVAL);
}
function stopPublishing() {
if (publishTimer) { clearInterval(publishTimer); publishTimer = null; }
if (wpCheckTimer) { clearInterval(wpCheckTimer); wpCheckTimer = null; }
}
function stopAll() {
stopPublishing();
stopTelemetryLoop();
}
// ---- ROS Service calls ----
// Simple one-shot versions for arm/disarm/land buttons
// ---- Mission operations ----
function uploadMission(waypoints) {
if (waypoints.length === 0) {
UIModule.onStatus({ type: 'error', message: '航点列表为空' });
return;
}
// Store raw waypoints
rawWaypoints = waypoints;
currentWPIndex = 0;
// Convert to ENU (origin should always be set by now)
if (originSet) {
convertWaypoints();
UIModule.onStatus({ type: 'success', message: `任务上传成功,共 ${navWaypoints.length} 个航点` });
} else {
// Fallback (shouldn't happen, but just in case)
UIModule.onStatus({ type: 'success', message: `已暂存 ${waypoints.length} 个航点` });
}
}
function convertWaypoints() {
if (!originSet || !rawWaypoints || rawWaypoints.length === 0) return false;
navWaypoints = rawWaypoints.map(wp => {
const enu = latLngToENU(wp.lat, wp.lng, wp.alt);
return { ...enu, hold_time: wp.hold_time || 0 };
});
return true;
}
function startMission() {
if (!rosConnected || !droneConnected) {
UIModule.onStatus({ type: 'error', message: '未连接无人机' });
return;
}
if (!fcuReady) {
UIModule.onStatus({ type: 'error', message: '飞控未就绪 (MAVROS 模式为空)。请等待 PX4 完全启动后重试。也可尝试在终端执行: rosservice call ' + serviceName('/mavros/set_mode') + ' "{base_mode: 0, custom_mode: \'OFFBOARD\'}" 验证模式切换是否正常' });
return;
}
if (rawWaypoints.length === 0) {
UIModule.onStatus({ type: 'error', message: '请先上传任务' });
return;
}
// Convert waypoints
if (navWaypoints.length === 0 && rawWaypoints.length > 0) {
if (!originSet) setDefaultOrigin();
convertWaypoints();
}
if (navWaypoints.length === 0) {
UIModule.onStatus({ type: 'error', message: '航点转换失败,请重新上传任务' });
return;
}
// Step 1: Start sending takeoff setpoints at target altitude
// PX4 requires setpoints BEFORE switching OFFBOARD, and altitude must be positive
const firstWP = navWaypoints[0];
const startX = telemetry.localPos.x || 0;
const startY = telemetry.localPos.y || 0;
const takeoffAlt = firstWP.z;
startPublishLoop(startX, startY, takeoffAlt);
UIModule.onStatus({ type: 'info', message: '正在发送位置设定值 (10Hz)...' });
// Step 2: Wait for setpoints to flow, then switch OFFBOARD
setTimeout(() => {
callSetModeWithRetry('OFFBOARD', 3, (ok) => {
if (!ok) {
stopPublishing();
UIModule.onStatus({ type: 'error', message: 'OFFBOARD 模式切换失败,任务中止。请在终端验证: rosservice call ' + serviceName('/mavros/set_mode') + ' "{base_mode: 0, custom_mode: \'OFFBOARD\'}"' });
return;
}
// Step 3: Arm
setTimeout(() => {
callArmingWithRetry(true, 3, (ok) => {
if (!ok) {
stopPublishing();
UIModule.onStatus({ type: 'error', message: '电机解锁失败,任务中止。请检查: 1) 传感器是否校准 2) GPS是否定位 3) 安全检查是否通过' });
return;
}
// Step 4: Wait for takeoff, then navigate
setTimeout(() => {
UIModule.onStatus({ type: 'info', message: '正在起飞...' });
// Check if drone reached takeoff altitude
let takeoffDone = false;
const takeoffCheck = setInterval(() => {
const curZ = telemetry.localPos.z || 0;
if (curZ > takeoffAlt - 1.0 && curZ > 0.5) {
clearInterval(takeoffCheck);
takeoffDone = true;
navActive = true;
navPaused = false;
UIModule.onStatus({ type: 'success', message: '起飞完成,开始巡航' });
navigateToWaypoint(0);
}
}, 500);
// Fallback: start navigation after 15s regardless
setTimeout(() => {
clearInterval(takeoffCheck);
if (!takeoffDone) {
navActive = true;
navPaused = false;
UIModule.onStatus({ type: 'info', message: '任务已开始执行' });
navigateToWaypoint(0);
}
}, 15000);
}, 1000);
});
}, 500);
});
}, 3000);
}
// Retry wrappers for service calls
function callSetModeWithRetry(mode, retries, callback) {
UIModule.onStatus({ type: 'info', message: `正在切换 ${mode} 模式...` });
const service = new ROSLIB.Service({
ros: ros,
name: serviceName('/mavros/set_mode'),
serviceType: 'mavros_msgs/SetMode'
});
service.callService(
new ROSLIB.ServiceRequest({ base_mode: 0, custom_mode: mode }),
(result) => {
if (result && result.mode_sent) {
UIModule.onStatus({ type: 'success', message: `模式已切换: ${mode}` });
callback(true);
} else if (retries > 0) {
UIModule.onStatus({ type: 'warning', message: `模式切换未成功,剩余重试 ${retries} 次...` });
setTimeout(() => callSetModeWithRetry(mode, retries - 1, callback), 2000);
} else {
UIModule.onStatus({ type: 'error', message: `模式切换失败: ${JSON.stringify(result)}` });
callback(false);
}
},
(error) => {
if (retries > 0) {
UIModule.onStatus({ type: 'warning', message: `模式切换服务错误,重试中...` });
setTimeout(() => callSetModeWithRetry(mode, retries - 1, callback), 2000);
} else {
UIModule.onStatus({ type: 'error', message: `模式切换服务失败: ${error || '不可用'}` });
callback(false);
}
}
);
}
function callArmingWithRetry(arm, retries, callback) {
UIModule.onStatus({ type: 'info', message: arm ? '正在解锁电机...' : '正在上锁电机...' });
const service = new ROSLIB.Service({
ros: ros,
name: serviceName('/mavros/cmd/arming'),
serviceType: 'mavros_msgs/CommandBool'
});
service.callService(
new ROSLIB.ServiceRequest({ value: arm }),
(result) => {
if (result && result.success) {
UIModule.onStatus({ type: 'success', message: arm ? '电机已解锁' : '电机已上锁' });
callback(true);
} else if (retries > 0) {
UIModule.onStatus({ type: 'warning', message: `${arm ? '解锁' : '上锁'}未成功 (result=${result.result}),剩余重试 ${retries} 次...` });
setTimeout(() => callArmingWithRetry(arm, retries - 1, callback), 2000);
} else {
UIModule.onStatus({ type: 'error', message: `${arm ? '解锁' : '上锁'}失败: result=${result ? result.result : 'N/A'}` });
callback(false);
}
},
(error) => {
if (retries > 0) {
UIModule.onStatus({ type: 'warning', message: `${arm ? '解锁' : '上锁'}服务错误,重试中...` });
setTimeout(() => callArmingWithRetry(arm, retries - 1, callback), 2000);
} else {
UIModule.onStatus({ type: 'error', message: `${arm ? '解锁' : '上锁'}服务失败` });
callback(false);
}
}
);
}
// Simple one-shot mode switch (no retry, used by returnHome/land)
function callSetMode(mode) {
callSetModeWithRetry(mode, 1, () => {});
}
function navigateToWaypoint(index) {
if (!navActive || navPaused) return;
if (index >= navWaypoints.length) {
UIModule.onStatus({ type: 'success', message: '所有航点已到达,任务完成' });
const p = telemetry.localPos;
startPublishLoop(p.x, p.y, p.z);
navActive = false;
return;
}
currentWPIndex = index;
const wp = navWaypoints[index];
UIModule.onStatus({ type: 'info', message: `飞往航点 ${index + 1}/${navWaypoints.length} (${wp.x.toFixed(1)}, ${wp.y.toFixed(1)}, ${wp.z.toFixed(1)}m)` });
startPublishLoop(wp.x, wp.y, wp.z);
checkWaypointReached(index);
}
function checkWaypointReached(index) {
if (wpCheckTimer) clearInterval(wpCheckTimer);
const wpTimeout = setTimeout(() => {
// Fallback: if waypoint not reached in 30s, advance anyway
if (navActive && !navPaused) {
UIModule.onStatus({ type: 'warning', message: `航点 ${index + 1} 到达超时,跳至下一航点` });
navigateToWaypoint(index + 1);
}
}, 30000);
wpCheckTimer = setInterval(() => {
if (!navActive || navPaused) {
clearInterval(wpCheckTimer);
clearTimeout(wpTimeout);
return;
}
const wp = navWaypoints[index];
if (!wp) { clearTimeout(wpTimeout); return; }
const p = telemetry.localPos;
const dx = p.x - wp.x;
const dy = p.y - wp.y;
const dz = p.z - wp.z;
const dist = Math.sqrt(dx * dx + dy * dy + dz * dz);
if (dist < WP_REACH_DIST) {
clearInterval(wpCheckTimer);
clearTimeout(wpTimeout);
wpCheckTimer = null;
UIModule.onTelemetry({
type: 'mission_progress',
current: index + 1,
total: navWaypoints.length
});
const holdTime = wp.hold_time || 0;
if (holdTime > 0) {
UIModule.onStatus({ type: 'info', message: `航点 ${index + 1} 已到达,悬停 ${holdTime}` });
setTimeout(() => {
if (navActive && !navPaused) navigateToWaypoint(index + 1);
}, holdTime * 1000);
} else {
navigateToWaypoint(index + 1);
}
}
}, 500);
}
function pauseMission() {
if (!navActive || navPaused) return;
navPaused = true;
if (wpCheckTimer) { clearInterval(wpCheckTimer); wpCheckTimer = null; }
const p = telemetry.localPos;
startPublishLoop(p.x, p.y, p.z);
UIModule.onStatus({ type: 'info', message: '任务已暂停(悬停中)' });
}
function resumeMission() {
if (!navActive || !navPaused) return;
navPaused = false;
navigateToWaypoint(currentWPIndex);
UIModule.onStatus({ type: 'info', message: '任务已继续' });
}
function returnHome() {
stopPublishing();
navActive = false;
navPaused = false;
UIModule.onStatus({ type: 'warning', message: '正在返航...' });
// Fly to origin at current altitude
startPublishLoop(0, 0, telemetry.localPos.z);
const homeCheck = setInterval(() => {
const p = telemetry.localPos;
const dist = Math.sqrt(p.x * p.x + p.y * p.y);
if (dist < WP_REACH_DIST) {
clearInterval(homeCheck);
// Switch to AUTO.LAND for safe landing
callSetMode('AUTO.LAND');
setTimeout(() => stopPublishing(), 3000);
UIModule.onStatus({ type: 'info', message: '已到达起飞点上方,正在降落...' });
}
}, 500);
}
function land() {
stopPublishing();
navActive = false;
navPaused = false;
// Use PX4 AUTO.LAND mode (safest)
callSetMode('AUTO.LAND');
UIModule.onStatus({ type: 'warning', message: '正在降落 (AUTO.LAND)...' });
}
function arm() {
callArmingWithRetry(true, 2, () => {});
}
function disarm() {
callArmingWithRetry(false, 2, () => {});
}
return {
init,
isConnected,
connectDrone,
uploadMission,
startMission,
pauseMission,
resumeMission,
returnHome,
land,
arm,
disarm
};
})();

@ -0,0 +1,39 @@
"""
Flask 静态文件服务器 - 托管前端页面
浏览器通过 roslibjs 直接连接 rosbridge_server无需 Python 后端处理无人机逻辑
"""
import os
from flask import Flask, send_from_directory
# 项目根目录server 的上级目录)
BASE_DIR = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
app = Flask(__name__, static_folder=BASE_DIR)
@app.route("/")
def index():
return send_from_directory(BASE_DIR, "index.html")
@app.route("/css/<path:filename>")
def css_files(filename):
return send_from_directory(os.path.join(BASE_DIR, "css"), filename)
@app.route("/js/<path:filename>")
def js_files(filename):
return send_from_directory(os.path.join(BASE_DIR, "js"), filename)
if __name__ == "__main__":
print("=" * 50)
print(" P600 无人机定点巡航控制系统")
print(" 打开浏览器访问: http://localhost:5000")
print()
print(" 架构: 浏览器 ──roslibjs──► rosbridge_server ──ROS──► PX4")
print(" rosbridge 地址默认: ws://localhost:9090 (仿真)")
print(" ws://192.168.1.31:9090 (实机)")
print("=" * 50)
app.run(host="0.0.0.0", port=5000, debug=False)

@ -0,0 +1,7 @@
# 此文件已弃用 - 新架构通过 roslibjs + rosbridge_server 直接在浏览器端控制无人机
# 无需 Python 后端处理无人机逻辑
#
# 新架构:
# 浏览器 (roslibjs) ──WebSocket──► rosbridge_server ──ROS话题──► Prometheus/MAVROS ──► PX4
#
# 如需参考旧版 MAVSDK 实现,请查看 git 历史

@ -0,0 +1,310 @@
# P600 无人机定点巡航控制系统 - 使用说明
## 系统简介
本系统是一个基于 Web 的 P600 无人机定点巡航控制前端,支持在地图上选择航点并让无人机按指定路径飞行。前端通过 **roslibjs** 直接连接机载 **rosbridge_server**,利用 ROS 话题和服务控制无人机,兼容**仿真模式**Prometheus SITL和**实机模式**P600 WiFi 数传)。
## 系统架构
```
Windows 浏览器 ──WebSocket──► rosbridge_server ──ROS话题/服务──► PX4 飞控
(地图+控制面板) (机载/仿真 ROS) (Prometheus/MAVROS)
```
- 前端通过 Leaflet 地图进行航点规划,通过 **roslibjs** 与 rosbridge_server 实时通信
- rosbridge_server 将 WebSocket 消息转换为 ROS 话题发布/订阅和服务调用
- 控制指令通过 `/prometheus/control_command` 话题发送到 Prometheus 控制节点
- 无人机状态通过 `/prometheus/drone_state` 话题接收
- 解锁/模式切换通过 MAVROS 服务(`/mavros/cmd/arming`、`/mavros/set_mode`)实现
- QGC 可同时连接同一无人机做监控,互不干扰
## 项目文件结构
```
软件前端/
├── index.html # 主页面
├── css/
│ └── style.css # 样式文件
├── js/
│ ├── map.js # 地图模块Leaflet 航点管理)
│ ├── websocket.js # 通信模块roslibjs + rosbridge
│ └── ui.js # 交互逻辑(按钮状态、日志)
└── server/
├── app.py # Flask 静态文件服务器(仅托管页面)
└── requirements.txt # Python 依赖(仅 flask
```
---
## 一、环境准备
### 1.1 安装 Python 依赖(仅用于启动静态文件服务器)
```bash
cd server
pip install -r requirements.txt
```
依赖内容:
| 包名 | 版本 | 作用 |
|------|------|------|
| flask | 3.0.0 | Web 框架,托管前端页面 |
### 1.2 确认 Python 版本
需要 **Python 3.8+**
```bash
python --version
```
### 1.3 ROS 端依赖(在无人机机载电脑或仿真环境中)
需要在 ROS 环境中安装并运行以下组件:
| 组件 | 作用 |
|------|------|
| rosbridge_suite | WebSocket-ROS 桥接,允许浏览器通过 WebSocket 与 ROS 通信 |
| prometheus_msgs | Prometheus 自定义消息类型ControlCommand、DroneState |
| MAVROS | ROS-MAVLink 桥接,提供服务调用(解锁、模式切换等) |
安装 rosbridge_suite
```bash
sudo apt install ros-<ros>-rosbridge-suite
# 例如 Ubuntu 20.04 + ROS Noetic:
sudo apt install ros-noetic-rosbridge-suite
```
---
## 二、仿真模式使用
> 适用场景:在电脑上调试,不需要真机,使用 Prometheus SITL 仿真
### 2.1 启动仿真环境(在 Ubuntu 虚拟机中)
```bash
# 1. 启动 Prometheus SITL 仿真
cd ~/Prometheus/...
roslaunch prometheus_gazebo sitl.launch
# 2. 在新终端中启动 rosbridge_server
roslaunch rosbridge_server rosbridge_websocket.launch
```
等待 Gazebo 窗口出现、PX4 终端显示 `pxh>` 即表示仿真就绪。rosbridge 默认监听端口 **9090**
> **提示**:如果后端运行在 Windows 上,仿真在虚拟机中,需要确保虚拟机网络为**桥接模式**,并能从 Windows 访问虚拟机 IP。
### 2.2 启动前端页面服务器
```bash
cd server
python app.py
```
浏览器访问:`http://localhost:5000`
### 2.3 前端操作
1. 页面顶栏点击 **「仿真模式」**,地址栏自动填入 `ws://localhost:9090`
- 如果仿真在虚拟机中,地址改为 `ws://<虚拟机IP>:9090`
2. 点击 **「连接无人机」**,等待连接成功提示
3. 在左侧地图上**点击添加航点**(可拖拽调整位置)
4. 在右侧面板设置每个航点的**高度**和**悬停时间**
5. 点击 **「上传任务」** → **「开始任务」**
6. 系统将自动:切换 OFFBOARD 模式 → 解锁电机 → 起飞 → 按航点顺序飞行
7. 可随时 **「暂停」** / **「继续」** / **「返航」** / **「降落」**
---
## 三、实机模式使用
> 适用场景:连接真实的 P600 无人机进行飞行
### 3.1 前置准备
1. P600 无人机**已上电**
2. 电脑连接 P600 的 **WiFi 数传**WiFi 名称和密码见无人机底部标签)
3. 确认能 ping 通机载电脑 IP默认 `192.168.1.31`
4. 确认机载电脑上 **rosbridge_server 已启动**
```bash
# SSH 登录机载电脑后执行
roslaunch rosbridge_server rosbridge_websocket.launch
```
如果 rosbridge 已集成在 Prometheus 启动脚本中则无需手动启动。
### 3.2 启动前端页面服务器
```bash
cd server
python app.py
```
浏览器访问:`http://localhost:5000`
### 3.3 前端操作
1. 页面顶栏点击 **「实机模式」**,地址栏自动填入 `ws://192.168.1.31:9090`
2. 点击 **「连接无人机」**,等待连接成功
3. 在地图上规划航点 → 上传任务 → 开始任务(操作流程与仿真相同)
> **注意**实机飞行时QGC 可同时连接MAVLink 协议支持多客户端),用于辅助监控无人机状态。
---
## 四、界面说明
### 4.1 顶部状态栏
| 区域 | 内容 |
|------|------|
| 左侧 | 系统名称、连接状态标签、飞行模式、电量、GPS 状态、速度、高度 |
| 右侧 | 仿真/实机模式切换、rosbridge 地址输入框、连接按钮 |
### 4.2 左侧地图区域
- **左键点击**:添加航点(蓝色编号标记)
- **蓝色虚线**:航点之间的飞行路径
- **蓝色三角图标**:无人机实时位置(随航向旋转)
- **红色实线**:无人机已飞过的航迹
- **拖拽标记**:移动航点位置
### 4.3 右侧控制面板
| 模块 | 功能 |
|------|------|
| 航点列表 | 显示所有航点的经纬度、高度;点击可定位;可单独删除 |
| 默认参数 | 设置新建航点的默认高度(米)和悬停时间(秒) |
| 任务操作 | 上传任务、开始任务、暂停/继续、返航、降落、清除航点 |
| 实时数据 | 纬度、经度、航向、当前航点进度 |
### 4.4 底部日志栏
- 实时显示操作记录和状态消息
- 颜色区分:绿色=成功,红色=错误,黄色=警告,灰色=信息
---
## 五、工作原理
### 5.1 通信流程
```
浏览器 (roslibjs) ──WebSocket──► rosbridge_server (端口 9090)
┌────────────────┼────────────────┐
▼ ▼ ▼
/prometheus/ /prometheus/ /mavros/
control_command drone_state cmd/arming
(发布控制指令) (订阅无人机状态) (服务调用)
```
### 5.2 航点导航机制
本系统采用**前端控制的逐航点导航**模式:
1. 用户在地图上添加航点(经纬度 + 高度)
2. 点击「上传任务」时,前端将经纬度转换为 ENU 坐标(以东经北天为坐标轴)
3. 点击「开始任务」后,系统按以下顺序执行:
- 先发布控制指令OFFBOARD 模式需要先有指令流)
- 切换 OFFBOARD 模式MAVROS 服务调用)
- 解锁电机MAVROS 服务调用)
- 发送起飞指令Prometheus TAKEOFF 命令)
- 逐个航点发送 MOVE 指令,以 10Hz 频率持续发布
- 检测到达航点(距离 < 2
4. 所有航点完成后自动悬停
### 5.3 坐标转换
- 地图使用 WGS84 经纬度坐标
- ROS/Prometheus 使用 ENU东-北-天)局部坐标系
- 系统在首次获取 GPS 定位时自动设置坐标原点
- 所有航点在「上传任务」时从经纬度转换为相对于原点的 ENU 坐标
---
## 六、按钮状态说明
| 状态 | 上传任务 | 开始任务 | 暂停 | 继续 | 返航 | 降落 |
|------|----------|----------|------|------|------|------|
| 未连接 | 禁用 | 禁用 | 禁用 | 禁用 | 禁用 | 禁用 |
| 已连接(有航点) | 启用 | 禁用 | 禁用 | 禁用 | 启用 | 启用 |
| 任务已上传 | 禁用 | 启用 | 禁用 | 禁用 | 启用 | 启用 |
| 任务执行中 | 禁用 | 禁用 | 启用 | 禁用 | 启用 | 启用 |
| 任务已暂停 | 禁用 | 禁用 | 禁用 | 启用 | 启用 | 启用 |
---
## 七、完整操作流程
```
┌──────────┐ ┌──────────┐ ┌──────────┐ ┌──────────┐
│ 启动 ROS │────►│ 连接前端 │────►│ 地图添加航点│────►│ 上传任务 │
│ SITL/实机 │ │ 选择模式 │ │ 点击地图 │ │ 转换坐标 │
└──────────┘ └──────────┘ └──────────┘ └────┬─────┘
┌──────────┐ ┌──────────┐ ┌──────────┐ │
│ 任务完成 │◄────│ 飞行监控 │◄────│ 开始任务 │◄─────────┘
│ 或返航降落 │ │ 实时遥测 │ │ 解锁→起飞 │
└──────────┘ └──────────┘ └──────────┘
```
---
## 八、连接参数参考
| 参数 | 仿真模式 | 实机模式 |
|------|----------|----------|
| 协议 | WebSocket | WebSocket |
| rosbridge 地址 | `ws://localhost:9090` | `ws://192.168.1.31:9090` |
| 连接对象 | 本地/虚拟机 rosbridge | P600 机载电脑 rosbridge |
| 网络要求 | 本地或虚拟机网络 | P600 WiFi 数传 |
| ROS 话题(控制) | `/prometheus/control_command` | `/prometheus/control_command` |
| ROS 话题(状态) | `/prometheus/drone_state` | `/prometheus/drone_state` |
| ROS 话题GPS | `/mavros/global_position/raw/fix` | `/mavros/global_position/raw/fix` |
| ROS 服务(解锁) | `/mavros/cmd/arming` | `/mavros/cmd/arming` |
| ROS 服务(模式) | `/mavros/set_mode` | `/mavros/set_mode` |
---
## 九、常见问题
### 连接超时怎么办?
**仿真模式:**
- 确认 Prometheus SITL 已启动(`roslaunch prometheus_gazebo sitl.launch`
- 确认 rosbridge_server 已启动(`roslaunch rosbridge_server rosbridge_websocket.launch`
- 如果仿真在虚拟机中,确认 Windows 能 ping 通虚拟机 IP
- 确认虚拟机网络为桥接模式
**实机模式:**
- 确认电脑已连接 P600 的 WiFi 数传
- 在终端执行 `ping 192.168.1.31` 确认网络通畅
- 确认无人机已上电
- 确认机载电脑上 rosbridge_server 正在运行
- 关闭 VPN/防火墙等可能干扰网络的软件
### 航点任务执行失败?
- 确认无人机 GPS 已定位(状态栏显示 "3D Fix"
- 航点高度建议设置在 10 米以上
- 确认无人机已通过安全检查(传感器校准、解锁检查等)
- 检查日志中的 ROS 话题和服务调用是否成功
### Prometheus 话题收不到数据?
- 确认 Prometheus 控制节点正在运行
- 在 ROS 端检查话题是否存在:`rostopic list | grep prometheus`
- 检查话题数据:`rostopic echo /prometheus/drone_state`
- 确认 `prometheus_msgs` 包已正确编译和 source
### 如何同时使用 QGC 监控?
MAVLink 协议支持多客户端同时连接。QGC 通过 MAVLink 连接UDP 14550本系统通过 rosbridge 连接WebSocket 9090两者互不干扰监控的是同一架无人机。
### 默认航点参数可以改吗?
可以。右侧面板的「默认高度」和「默认悬停时间」会影响后续新建的航点。已创建的航点参数不受影响。

File diff suppressed because it is too large Load Diff

@ -0,0 +1,24 @@
{
"created_time": "2026-04-20T02:11:45Z",
"files": [
{
"attachment_folder": "",
"created_time": "2026-04-20T02:11:45Z",
"id": "1543",
"modified_time": "2026-04-20T02:11:45Z",
"name": "README.md",
"signature": "1126905480722352993",
"tags": [
]
}
],
"folders": [
{
"name": "document"
}
],
"id": "1541",
"modified_time": "2026-04-20T02:11:45Z",
"signature": "5155401718504459105",
"version": 3
}
Loading…
Cancel
Save